From dd792cd7921f6467e7724db499480e25c0350102 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 28 Sep 2026 17:34:38 +0800 Subject: [PATCH 001/176] =?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 --- .../V4__cargo_discard_return_inventory.sql | 7 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 4 +- .../sql/海外货物废弃出库字段补齐-MySQL8.sql | 75 +++++++++++++++ .../controller/CargoDiscardController.java | 4 +- .../discard/dto/CargoDiscardResponses.java | 4 +- .../entity/CargoDiscardApplication.java | 2 + .../discard/service/CargoDiscardService.java | 96 ++++++++++++++++++- .../lastleg/relabel/dto/RelabelRequests.java | 5 +- .../lastleg/relabel/dto/RelabelResponses.java | 3 +- .../service/RelabelApplicationService.java | 26 ++++- .../MerchantReturnOrderController.java | 5 + .../dto/WarehouseTransferRequests.java | 5 +- .../dto/WarehouseTransferResponses.java | 4 +- .../entity/WarehouseTransferOrder.java | 2 + .../mapper/WarehouseTransferOrderMapper.java | 4 +- .../service/WarehouseTransferService.java | 49 +++++----- .../src/main/resources/db/lastleg.sql | 8 +- .../openapi/overseas-business-v1.yaml | 7 ++ ...rchantOverseasCustomerApiContractTest.java | 1 + ...chantRelabelApplicationControllerTest.java | 3 +- ...rchantWarehouseTransferControllerTest.java | 3 +- .../service/WarehouseTransferServiceTest.java | 68 +++++++++++-- scripts/db/incremental.sql | 10 +- 23 files changed, 338 insertions(+), 57 deletions(-) create mode 100644 deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql index ff1784f50..308970df7 100644 --- a/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql @@ -3,6 +3,10 @@ ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID', MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID'; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='completed_at'), + 'ALTER TABLE ll_cargo_discard_application ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''废弃完成时间'' AFTER reviewed_at', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); @@ -22,3 +26,6 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), + 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 176fe0178..4c89ab0b5 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -95,7 +95,7 @@ USE baiyue_lastleg; CREATE TABLE ll_cargo_discard_application ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', + discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', outbound_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '废弃出库单号', overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', inbound_codes varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号集合', @@ -112,7 +112,7 @@ CREATE TABLE ll_cargo_discard_application ( review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', - reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', completed_at datetime(3) DEFAULT NULL COMMENT '废弃完成时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), diff --git a/deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql b/deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql new file mode 100644 index 000000000..ab43063ab --- /dev/null +++ b/deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql @@ -0,0 +1,75 @@ +-- 货物废弃申请 + 废弃出库单字段补齐 +-- 目标数据库:baiyue_lastleg +-- MySQL 8.0;脚本可重复执行,已存在的字段/索引会跳过。 +USE baiyue_lastleg; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id'), + 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID'' AFTER outbound_order_id', + IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND (is_nullable <> 'YES' OR COALESCE(column_comment,'') <> '仓库实物托盘ID')), + 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE outbound_pallet_schema FROM @ddl; EXECUTE outbound_pallet_schema; DEALLOCATE PREPARE outbound_pallet_schema; + + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), + 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='completed_at'), + 'ALTER TABLE ll_cargo_discard_application ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''废弃完成时间'' AFTER reviewed_at', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='order_id' AND is_nullable='NO'), + 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN order_id BIGINT NULL COMMENT ''一件代发订单ID''', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='profile_id' AND is_nullable='NO'), + 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NULL COMMENT ''托盘档案ID''', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_inventory_id' AND is_nullable='NO'), + 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT ''SKU库存记录ID''', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:DROPSHIP/RETURN_CARTON'' AFTER order_id', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA状态:PENDING/COMPLETED'' AFTER actual_discard_packages', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), + 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), + 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), + 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''收件/业务说明'' AFTER lx_codes', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'), + 'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''请求摘要'' AFTER recipient_info', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), + 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU''', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks'), + 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''箱唛快照'' AFTER sku_code', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- 退货库存废弃需要允许中间锁定状态;若本地已有约束,按名称重建。 +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), + 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); + +SELECT 'cargo discard/outbound schema patch completed' AS result, DATABASE() AS database_name; 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 0c6552627..9ba668cdf 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 @@ -87,7 +87,9 @@ public class CargoDiscardController { private void requireApprovalPermission(AuthenticatedUser user) { if (user == null || user.permissions() == null - || !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) { + || !(user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION) + || user.permissions().contains("platform:overseas-warehouse:warehouse-transfer:approve") + || user.permissions().contains("platform:overseas-warehouse:discard:approve"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required"); } } 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 1fa1c8e8f..97098e3ae 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 @@ -14,10 +14,10 @@ public final class CargoDiscardResponses { List skus) {} public record CandidateSku(String skuCode, String productName, long availablePackages) {} - public record Summary(Long id, String discardNo, String inboundCodes, + public record Summary(Long id, String discardNo, String outboundNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, - String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, LocalDateTime completedAt) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java index 369ece1f4..979b8ca67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -14,6 +14,7 @@ public class CargoDiscardApplication { @TableId(type = IdType.INPUT) private Long id; private String customerId; private String discardNo; + private String outboundNo; private String overseasWarehouseId; private String overseasWarehouseName; private String inboundCodes; @@ -29,6 +30,7 @@ public class CargoDiscardApplication { private String reviewedBy; private String reviewRemark; private LocalDateTime reviewedAt; + private LocalDateTime completedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } 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 c7d1268ef..825a89623 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 @@ -16,8 +16,14 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.dropship.entity.DropshipCarton; import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; @@ -47,6 +53,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.Objects; +import java.util.stream.Collectors; @Service public class CargoDiscardService { @@ -68,6 +76,9 @@ public class CargoDiscardService { @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPalletItems; + @Resource private DropshipOrderProfileMapper profiles; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ObjectMapper objectMapper; @@ -269,6 +280,10 @@ public class CargoDiscardService { release(customerId, links, applicationItems); } + if (APPROVED.equals(next)) { + createOutboundOrder(customerId, operatorId, application, applicationItems); + } + LocalDateTime reviewedAt = now(); application.setStatus(next); application.setReviewedBy(operatorId); application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt); @@ -277,6 +292,9 @@ public class CargoDiscardService { if (linked != null) { String linkedStatus = mainOrderStatus(next); linked.setCurrentStatus(linkedStatus); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt); + if (APPROVED.equals(next) && StringUtils.hasText(application.getOutboundNo())) { + linked.setOutboundOrderNo(application.getOutboundNo()); + } orders.updateById(linked); LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId()); orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW); @@ -287,6 +305,68 @@ public class CargoDiscardService { return detail(customerId, discardNo); } + private void createOutboundOrder(String customerId, String operatorId, CargoDiscardApplication application, + List applicationItems) { + // Unit tests that exercise the approval state transition without the delivery module + // do not wire these optional persistence collaborators; the running service always does. + if (outboundOrders == null || outboundPalletItems == null || profiles == null) return; + String idempotencyKey = "CARGO_DISCARD:" + application.getId(); + OutboundOrder existing = outboundOrders.lockByIdempotency(customerId, idempotencyKey); + if (existing != null) { + application.setOutboundNo(existing.getOutboundNo()); + return; + } + LocalDateTime now = now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); + outbound.setCustomerId(customerId); + outbound.setOutboundNo(orderNoGenerator.next("DISCARD_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); + outbound.setOutboundType("CARGO_DISCARD_OUTBOUND"); + outbound.setStatus("WAIT_PICK"); + outbound.setRecipientInfo("货物废弃:" + application.getDiscardNo()); + outbound.setPackageCount(applicationItems.stream().mapToInt(i -> Objects.requireNonNullElse(i.getDiscardPackages(), 0)).sum()); + outbound.setPalletCount(applicationItems.stream().map(this::physicalKey).collect(Collectors.toSet()).size()); + outbound.setIdempotencyKey(idempotencyKey); + outbound.setCreatedBy(operatorId); + outbound.setCreatedAt(now); + outbound.setUpdatedAt(now); + outboundOrders.insert(outbound); + + for (CargoDiscardItem source : applicationItems) { + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); + item.setCustomerId(customerId); + item.setOutboundOrderId(outbound.getId()); + item.setPackageCount(Objects.requireNonNullElse(source.getDiscardPackages(), 0)); + item.setPickedPackageCount(0); + item.setStatus("WAIT_PICK"); + item.setSkuCode(source.getSkuCode()); + item.setInboundCode(source.getInboundCode()); + item.setOrderRefs(application.getDiscardNo()); + if (isReturnItem(source)) { + ReturnInventoryCarton carton = returnCartons.selectById(source.getReturnCartonId()); + item.setPalletNo(carton == null ? source.getInboundCode() : firstNonBlank(carton.getPalletCode(), carton.getCartonMark())); + item.setCartonMarks(carton == null ? null : carton.getCartonMark()); + } else { + DropshipOrderProfile profile = profiles.selectById(source.getProfileId()); + if (profile != null) { + item.setWarehousePalletId(profile.getWarehousePalletId()); + item.setPalletNo(firstNonBlank(profile.getPalletNo(), profile.getSourcePalletNo())); + } + } + item.setCreatedAt(now); + outboundPalletItems.insert(item); + } + application.setOutboundNo(outbound.getOutboundNo()); + application.setUpdatedAt(now); + applications.updateById(application); + } + + private String physicalKey(CargoDiscardItem item) { + return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId(); + } + @Transactional public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) { CargoDiscardApplication application = applications.lockNo(customerId, discardNo); @@ -299,7 +379,9 @@ public class CargoDiscardService { public void updateLinkedStatus(String customerId, CargoDiscardApplication application, String from, String to, String operatorId, String remark) { - LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application); + LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); + if (COMPLETED.equals(to)) application.setCompletedAt(at); + applications.updateById(application); LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId()); if (linked != null) { String linkedFrom = mainOrderStatus(from); @@ -420,22 +502,26 @@ public class CargoDiscardService { return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; } + private static String firstNonBlank(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } + private static long value(Long value) { return value == null ? 0L : value; } private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { LastlegOrder order = orders.selectById(orderId); if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); - if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { - throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order"); + if (!StringUtils.hasText(order.getOverseasWarehouseId())) { + throw conflict("Cargo discard inventory is not linked to an overseas warehouse"); } return order; } private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { - return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(), + return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getOutboundNo(), value.getInboundCodes(), value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), - value.getReviewedAt()); + value.getReviewedAt(), value.getCompletedAt()); } private void history(String customerId, Long applicationId, String from, String to, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 4595244e2..3947eaf58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -122,7 +122,10 @@ public final class RelabelRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, String customerOrderNo, String skuCode, String status, - String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, + LocalDateTime reviewedFrom, LocalDateTime reviewedTo, + LocalDateTime completedFrom, LocalDateTime completedTo, + LocalDateTime outboundFrom, LocalDateTime outboundTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } 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..db76931eb 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 @@ -18,7 +18,8 @@ public final class RelabelResponses { String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, - String reviewChannel, String reviewRemark, LocalDateTime createdAt) {} + String reviewChannel, String reviewRemark, LocalDateTime reviewedAt, + LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, LocalDateTime createdAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, String trackingCode, 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 f91799008..ed620709b 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 @@ -502,6 +502,12 @@ public class RelabelApplicationService implements WalletResultHandler { trim(query.overseasWarehouseId())) .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) + .apply(query.reviewedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at >= {0})", query.reviewedFrom()) + .apply(query.reviewedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at <= {0})", query.reviewedTo()) + .apply(query.completedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at >= {0})", query.completedFrom()) + .apply(query.completedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at <= {0})", query.completedTo()) + .apply(query.outboundFrom() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at >= {0})", query.outboundFrom()) + .apply(query.outboundTo() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at <= {0})", query.outboundTo()) .apply(StringUtils.hasText(query.skuCode()), "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", query.skuCode()) @@ -1049,8 +1055,24 @@ public class RelabelApplicationService implements WalletResultHandler { value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER", - null, - value.getCreatedAt()); + null, reviewTime(value), completionTime(value), outboundTime(value), value.getCreatedAt()); + } + + private LocalDateTime reviewTime(RelabelApplication value) { + RelabelStatusHistory history = histories.selectOne(new LambdaQueryWrapper() + .eq(RelabelStatusHistory::getApplicationId, value.getId()).eq(RelabelStatusHistory::getCustomerId, value.getCustomerId()) + .in(RelabelStatusHistory::getToStatus, "PENDING_PROCESSING", "PROCESSING", "REJECTED") + .orderByDesc(RelabelStatusHistory::getCreatedAt).last("LIMIT 1")); + return history == null ? null : history.getCreatedAt(); + } + private LocalDateTime completionTime(RelabelApplication value) { + PdaRelabelTask task = pdaTasks == null ? null : pdaTasks.find(value.getCustomerId(), value.getId()); + return task == null ? null : task.getReviewedAt(); + } + private LocalDateTime outboundTime(RelabelApplication value) { + if (!StringUtils.hasText(value.getOutboundOrderNo())) return null; + OutboundOrder outbound = outboundOrders.findByNo(value.getCustomerId(), value.getOutboundOrderNo()); + return outbound == null ? null : outbound.getCreatedAt(); } @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java index 09e870bb6..d83bcb4d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java @@ -24,6 +24,11 @@ public class MerchantReturnOrderController { @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @PutMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Create request) { + return ApiResponse.success(service.updatePending(customer(user), returnNo, request)); + } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 42804efba..4e3c3f8ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -168,7 +168,10 @@ public final class WarehouseTransferRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String transferNo, String inboundCode, String operatingWarehouseId, String fbaWarehouseCode, String status, Boolean needRelabel, - LocalDateTime createdFrom, LocalDateTime createdTo) { + LocalDateTime createdFrom, LocalDateTime createdTo, + LocalDateTime reviewedFrom, LocalDateTime reviewedTo, + LocalDateTime measuredFrom, LocalDateTime measuredTo, + LocalDateTime outboundFrom, LocalDateTime outboundTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } } 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 882719a4c..d7334bf94 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 @@ -31,7 +31,9 @@ public final class WarehouseTransferResponses { String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, String relabelApplicationStatus, String relabelFulfillmentStatus, - String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} + String status, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt, + LocalDateTime actualShipAt, LocalDateTime reviewedAt, LocalDateTime measuredAt, + LocalDateTime outboundAt, LocalDateTime relabelCompletedAt, LocalDateTime createdAt) {} 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/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index a994d1730..2356042cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -56,6 +56,8 @@ public class WarehouseTransferOrder { private String outboundIdempotencyKey; private String outboundRequestHash; private LocalDateTime outboundAt; + private LocalDateTime measuredAt; + private LocalDateTime actualShipAt; private String palletizeIdempotencyKey; private String palletizeRequestHash; private String confirmationIdempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index bf0be19c6..784e08681 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -20,10 +20,10 @@ import org.apache.ibatis.annotations.Update; WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") java.util.List pdaCandidates(@Param("warehouseId") String warehouseId); - @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") + @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},actual_ship_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("outboundAt") java.time.LocalDateTime outboundAt); - @Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.updated_at=#{outboundAt} " + + @Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.actual_ship_at=#{outboundAt},wto.updated_at=#{outboundAt} " + "WHERE wto.customer_id=#{customerId} AND wto.relabel_no=#{relabelNo} AND wto.outbound_at IS NULL " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_pallet wtp " + "JOIN ll_inventory_position ip ON ip.customer_id=wtp.customer_id AND ip.pallet_id=wtp.warehouse_pallet_id " + 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 f4c8b71fe..30be44835 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 @@ -147,12 +147,6 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); - // A split relabel transfer may intentionally defer the whole-order - // destination: relabeled cartons go through relabel dispatch while the - // remaining cartons are bound later from warehouse-transfer outbound. - // All other transfer types must still select FBA or PRIVATE up front. - validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(), - request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty()); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -272,8 +266,8 @@ public class WarehouseTransferService { WarehouseTransferRequests.Create request) { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if (!DRAFT.equals(transfer.getStatus())) { - throw conflict("Only a draft warehouse transfer order can be edited"); + if (!Set.of(DRAFT, REJECTED).contains(transfer.getStatus())) { + throw conflict("Only a draft or rejected warehouse transfer order can be edited"); } int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); if (itemPackages != request.packageCount()) { @@ -283,8 +277,6 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); - validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(), - request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty()); LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); @@ -320,7 +312,7 @@ public class WarehouseTransferService { order.setVolumeCbm(request.volumeCbm()); order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); order.setCustomerRemark(trim(request.remark())); order.setSourcePayloadHash("sha256:" + requestHash); order.setUpdatedAt(now); @@ -373,7 +365,10 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); - if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); + String previousStatus = transfer.getStatus(); + if (!Set.of(DRAFT, REJECTED).contains(previousStatus)) { + throw conflict("Only a draft or rejected warehouse transfer order can be submitted"); + } LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); @@ -388,12 +383,13 @@ public class WarehouseTransferService { LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode()) ? inboundOrders.selectById(transfer.getInboundOrderId()) : null; - if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) { + if (inbound != null && customerId.equals(inbound.getCustomerId()) + && Set.of(DRAFT, REJECTED, "CANCELLED").contains(inbound.getStatus())) { inbound.setStatus(PENDING_REVIEW); inbound.setUpdatedAt(now); inboundOrders.updateById(inbound); } - insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo, + insertOrderHistory(customerId, order.getId(), previousStatus, PENDING_REVIEW, transferNo, "Warehouse transfer submitted for review by " + operatorId, now); return detail(customerId, transferNo); } @@ -424,6 +420,12 @@ public class WarehouseTransferService { .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) + .ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom()) + .le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo()) + .ge(query.measuredFrom() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredFrom()) + .le(query.measuredTo() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredTo()) + .ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom()) + .le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo()) .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Map relabelApplicationsByNo = relabelApplications(result.getRecords()); @@ -436,7 +438,9 @@ public class WarehouseTransferService { relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); + value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(), + value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(), + value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -854,7 +858,7 @@ public class WarehouseTransferService { completedPalletItems, warehouse, now); completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now); completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now); - transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); + transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setMeasuredAt(now); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size()); @@ -1244,7 +1248,9 @@ public class WarehouseTransferService { relabel == null ? null : relabel.getStatus(), relabel == null ? null : relabel.getFulfillmentStatus(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getCreatedAt()); + value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(), + value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(), + value.getCreatedAt()); }).toList(); } @@ -1424,15 +1430,6 @@ public class WarehouseTransferService { } } - private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode, - boolean allowDeferredForRelabel) { - String type = trim(destinationType); - if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA"; - if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) { - throw badRequest("仓储转运必须选择 FBA 仓或私人仓"); - } - } - private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { String type = trim(request.destinationType()); if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a558dba68..5e82121fb 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1628,6 +1628,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at')); +PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at')); +PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); @@ -2056,7 +2060,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', @@ -2064,7 +2068,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', - reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + reviewed_at DATETIME(3) NULL COMMENT '审核时间', completed_at DATETIME(3) NULL COMMENT '废弃完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no), UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), 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 9f7836de1..cc4c5a626 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1556,6 +1556,13 @@ paths: security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } + put: + summary: 客户编辑待审核退货预报 + description: 权限 merchant:overseas-warehouse:return-order:create;仅允许修改当前客户且仍处于 PENDING_REVIEW 的退货预报,SKU 预报明细会整体替换。 + security: [{ bearerAuth: [] }] + parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } } + responses: { '200': { description: 更新后的退货订单详情 }, '404': { description: 当前客户下退货订单不存在 }, '409': { description: 退货预报已不处于待审核状态 } } /api/merchant/v1/last-leg/return-orders/{returnNo}/photos/files/{fileId}/download: get: summary: 客户下载退货签收打托照片 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index c547cb562..f12eb5cb3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -85,6 +85,7 @@ class MerchantOverseasCustomerApiContractTest { "cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL)); assertPermissions(MerchantReturnOrderController.class, Map.of( "create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE, + "update", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE, "page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, "detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, "downloadPhoto", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java index dce3a6aab..ed1a149e5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java @@ -17,7 +17,8 @@ class MerchantRelabelApplicationControllerTest { private final RelabelApplicationService service = mock(RelabelApplicationService.class); private final MerchantRelabelApplicationController controller = controller(); private final RelabelRequests.Query query = new RelabelRequests.Query( - 1, 20, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, + null, null, null, null, null, null); @Test void merchantPageAndDetailUseOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index ef731e388..91761ec10 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -17,7 +17,8 @@ class MerchantWarehouseTransferControllerTest { private final WarehouseTransferService service = mock(WarehouseTransferService.class); private final MerchantWarehouseTransferController controller = controller(); private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, + null, null, null, null, null, null); @Test void merchantPageUsesOnlyAuthenticatedTenant() { 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 864315768..4fa0efa85 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 @@ -120,7 +120,8 @@ class WarehouseTransferServiceTest { when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null)); + 1, 20, null, null, null, null, null, null, null, null, + null, null, null, null, null, null)); assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus()); @@ -387,19 +388,25 @@ class WarehouseTransferServiceTest { } @Test - void transferRejectsRequestWithoutDestination() { + void transferAllowsRequestWithoutDestinationForLaterBinding() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( "IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5, new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), null, null, null, "FBA destination will be supplied later", false, "key-no-fba", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.create("TENANT-A", "USER-1", request)); + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); + when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); - assertEquals(400, exception.getStatusCode().value()); - assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason()); - verifyNoInteractions(transfers); + var result = service.create("TENANT-A", "USER-1", request); + + assertEquals(WarehouseTransferService.DRAFT, result.status()); + ArgumentCaptor transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class); + verify(transfers).insert(transfer.capture()); + assertNull(transfer.getValue().getDestinationType()); } @Test @@ -611,6 +618,53 @@ class WarehouseTransferServiceTest { verify(orderDetails).insert(any(LastlegOrderDetail.class)); } + @Test + void merchantUpdatesRejectedTransferBeforeResubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.REJECTED); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)); + + assertEquals(WarehouseTransferService.REJECTED, result.status()); + assertTrue(tailOrder.getSubOrderStatus().startsWith("WAREHOUSE_TRANSFER:REJECTED")); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + } + + @Test + void merchantResubmitsRejectedTransferForReview() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.REJECTED); + transfer.setInboundCode("WTR-1"); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED"); + tailOrder.setRelabelStatus("NOT_REQUIRED"); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.submit("TENANT-A", "WTR-1", "USER-1"); + + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus()); + var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class); + verify(orderHistories).insert(history.capture()); + assertEquals(WarehouseTransferService.REJECTED, history.getValue().getFromStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, history.getValue().getToStatus()); + } + @Test void merchantCannotUpdateSubmittedTransfer() { WarehouseTransferOrder transfer = persistedTransfer(); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 74105200f..5b027ece5 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1369,7 +1369,7 @@ USE baiyue_lastleg; CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', @@ -1493,7 +1493,11 @@ PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLO SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); -PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at')); +PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at')); +PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -4304,3 +4308,5 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 2.54.0 From ed36c43af4ea29db45bc34c25f4d5aad76c04abc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 04:17:44 +0100 Subject: [PATCH 002/176] fix(firstleg): source sailing channels from active price book --- .../out/persistence/PriceBookQueryMapper.java | 22 +++++- .../application/PriceBookQueryService.java | 1 + .../application/model/PriceBookQueryRows.java | 1 + .../port/in/PriceBookQueryUseCase.java | 1 + .../port/out/PriceBookQueryReader.java | 1 + .../dto/SailingCalendarDtos.java | 2 +- .../service/SailingCalendarService.java | 77 +++++++++++++------ 7 files changed, 78 insertions(+), 27 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index 7c01a22c5..dbf759f82 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -43,7 +43,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { Long findEffectiveVersionId(@Param("customerId") long customerId); @Select(""" - SELECT c.price_book_version_id AS priceBookVersionId, + SELECT catalog.id AS channelCatalogId, c.price_book_version_id AS priceBookVersionId, c.channel_code AS channelCode, c.channel_name AS channelName, c.price_model AS priceModel, c.estimated_departure_date AS estimatedDepartureDate, @@ -53,6 +53,8 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { c.surcharge_description AS surchargeDescription, c.remark AS remark FROM fl_price_book_channel c + JOIN fl_price_channel_catalog catalog + ON catalog.channel_code = c.channel_code AND catalog.status = 'ACTIVE' WHERE c.price_book_version_id = #{versionId} AND EXISTS ( SELECT 1 FROM fl_carrier_fba_rate r @@ -66,6 +68,24 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { List findChannels(@Param("versionId") long versionId, @Param("originWarehouseId") long originWarehouseId); + @Select(""" + SELECT catalog.id AS channelCatalogId, c.price_book_version_id AS priceBookVersionId, + c.channel_code AS channelCode, c.channel_name AS channelName, + c.price_model AS priceModel, + c.estimated_departure_date AS estimatedDepartureDate, + c.estimated_departure_text AS estimatedDepartureText, + c.latest_warehouse_entry AS latestWarehouseEntry, + c.reference_transit AS referenceTransit, + c.surcharge_description AS surchargeDescription, + c.remark AS remark + FROM fl_price_book_channel c + JOIN fl_price_channel_catalog catalog + ON catalog.channel_code = c.channel_code AND catalog.status = 'ACTIVE' + WHERE c.price_book_version_id = #{versionId} + ORDER BY c.id + """) + List findAllChannels(@Param("versionId") long versionId); + @Select(""" SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode FROM fl_carrier_fba_rate r diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java index e3f07440e..48eceeaaa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -22,6 +22,7 @@ public class PriceBookQueryService implements PriceBookQueryUseCase { @Override public List channels(long versionId, long originWarehouseId) { return reader.findChannels(versionId, originWarehouseId); } + @Override public List allChannels(long versionId) { return reader.findAllChannels(versionId); } @Override public List channelTaxModes(long versionId, long originWarehouseId) { return reader.findChannelTaxModes(versionId, originWarehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java index 0ca9c0b8a..e969cf285 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java @@ -16,6 +16,7 @@ public final class PriceBookQueryRows { @Getter @Setter public static class ChannelRow { + private Long channelCatalogId; private Long priceBookVersionId; private String channelCode; private String channelName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java index 681279a2e..14a3e2d75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -11,6 +11,7 @@ public interface PriceBookQueryUseCase { Long activeVersionId(); Long effectiveVersionId(long customerId); List channels(long versionId, long originWarehouseId); + List allChannels(long versionId); List channelTaxModes(long versionId, long originWarehouseId); List fbaRates(long versionId, long originWarehouseId, String channelCode, String destinationCode, String pricingTaxMode); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java index d12366820..f441f1d68 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -11,6 +11,7 @@ public interface PriceBookQueryReader { Long findActiveVersionId(); Long findEffectiveVersionId(long customerId); List findChannels(long versionId, long originWarehouseId); + List findAllChannels(long versionId); List findChannelTaxModes(long versionId, long originWarehouseId); List findFbaRates(long versionId, long originWarehouseId, String channelCode, String destinationCode, String pricingTaxMode); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java index 4f44ad03d..e626f65ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java @@ -19,7 +19,7 @@ import java.util.List; public final class SailingCalendarDtos { private SailingCalendarDtos() {} - public record ChannelQuery(@Size(max = 64) String keyword, String status) {} + public record ChannelQuery(@Size(max = 64) String keyword, String status, @Positive Long warehouseId) {} public record ChannelData(@NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 128) String channelName, @NotBlank String sourceType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java index 5b3450f46..425510c4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -29,6 +29,8 @@ import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; @@ -41,30 +43,30 @@ public class SailingCalendarService { private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final String OPEN = "OPEN"; @Resource private SailingCalendarRepository repository; + @Resource private PriceBookQueryUseCase priceBooks; public List channels(ChannelQuery query) { - return repository.channels(query).stream().map(v -> new ChannelView(v, "ACTIVE".equals(v.getStatus()))).toList(); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null || "INACTIVE".equalsIgnoreCase(trim(query.status()))) return List.of(); + List rows = query.warehouseId() == null + ? priceBooks.allChannels(versionId) + : priceBooks.channels(versionId, query.warehouseId()); + String keyword = trimUpper(query.keyword()); + return rows.stream() + .filter(row -> !StringUtils.hasText(keyword) + || (row.getChannelCode() != null && row.getChannelCode().toUpperCase().contains(keyword)) + || (row.getChannelName() != null && row.getChannelName().toUpperCase().contains(keyword))) + .map(this::priceBookChannel) + .map(v -> new ChannelView(v, false)) + .toList(); } @Transactional public SailingChannelEntity createChannel(long operatorId, String operatorName, ChannelCreateRequest request) { - if (version(request.expectedVersion()) != 0) throw bad("新建渠道expectedVersion必须为0"); - validateChannel(request.data()); - if (repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在"); - SailingChannelEntity value = new SailingChannelEntity(); value.setId(IdWorker.getId()); - copy(value, request.data()); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); - value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); - try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("渠道代码已存在"); } - return value; + throw conflict("船期渠道来自当前生效船期价格表,不能手工维护"); } @Transactional public SailingChannelEntity updateChannel(long id, long operatorId, String operatorName, ChannelUpdateRequest request) { - SailingChannelEntity before = requiredChannel(id); validateChannel(request.data()); - if (!before.getChannelCode().equalsIgnoreCase(request.data().channelCode().trim()) - && repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在"); - SailingChannelEntity value = new SailingChannelEntity(); value.setId(id); copy(value, request.data()); - value.setStatus(normalizeStatus(request.status(), "渠道状态")); - if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("渠道已被其他人修改"); - return requiredChannel(id); + throw conflict("船期渠道来自当前生效船期价格表,不能手工维护"); } public PageResult templates(TemplateQuery query) { return repository.templates(query); } @@ -73,7 +75,7 @@ public class SailingCalendarService { @Transactional public SailingTemplateEntity createTemplate(long operatorId, String operatorName, TemplateCreateRequest request) { if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0"); - SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel); + SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板"); SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId()); copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); @@ -82,7 +84,7 @@ public class SailingCalendarService { } @Transactional public SailingTemplateEntity updateTemplate(long id, long operatorId, String operatorName, TemplateUpdateRequest request) { - requiredTemplate(id); SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel); + requiredTemplate(id); SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel); SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(id); copy(value, request.data(), channel); value.setStatus(StringUtils.hasText(request.status()) ? normalizeStatus(request.status(), "模板状态") : requiredTemplate(id).getStatus()); if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("模板已被其他人修改"); @@ -196,8 +198,8 @@ public class SailingCalendarService { throw bad("预计体积必须大于0"); } String channelCode = trimUpper(request.channelCode()); - SailingChannelEntity channel = StringUtils.hasText(channelCode) ? repository.channelByCode(channelCode) : null; - if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码"); + SailingChannelEntity channel = StringUtils.hasText(channelCode) ? resolvePriceBookChannelByCode(request.receivingWarehouseId(), channelCode) : null; + if (channel == null) throw bad("预报必须填写当前生效船期价格表中的渠道编码"); String departurePort = trimUpper(request.departurePortCode()); String destinationPort = trimUpper(request.destinationPortCode()); if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode()); @@ -225,8 +227,8 @@ public class SailingCalendarService { if (request.forecastVolumeCbm().signum() <= 0) throw bad("预计体积必须大于0"); String channelCode = trimUpper(request.channelCode()); if (!StringUtils.hasText(channelCode)) channelCode = current.getChannelCode(); - SailingChannelEntity channel = repository.channelByCode(channelCode); - if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码"); + SailingChannelEntity channel = resolvePriceBookChannelByCode(current.getReceivingWarehouseId(), channelCode); + if (channel == null) throw bad("预报必须填写当前生效船期价格表中的渠道编码"); String departurePort = trimUpper(request.departurePortCode()); String destinationPort = trimUpper(request.destinationPortCode()); if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode()); @@ -391,7 +393,7 @@ public class SailingCalendarService { @Transactional public List generate(long operatorId, String operatorName, long templateId, LocalDate from, LocalDate to) { SailingTemplateEntity template = requiredTemplate(templateId); if (!"ACTIVE".equals(template.getStatus())) throw conflict("停用模板不能生成船期"); - SailingChannelEntity channel = requiredChannel(template.getChannelId()); + SailingChannelEntity channel = resolvePriceBookChannel(template.getChannelId(), template.getWarehouseId(), template.getChannelCodeSnapshot()); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能生成船期"); validateTemplateSnapshot(template); List result = new java.util.ArrayList<>(); @@ -473,8 +475,33 @@ public class SailingCalendarService { if (d.etaAt() != null && d.etaAt().isBefore(d.etdAt())) throw bad("ETA不能早于ETD"); if (d.cutoffAt() == null || !d.cutoffAt().isBefore(d.etdAt())) throw bad("收货截止必须早于开船时间"); } - private SailingChannelEntity resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); if (!StringUtils.hasText(d.channelCode())) throw bad("必须选择价格表渠道"); SailingChannelEntity v = repository.channelByCode(d.channelCode().trim().toUpperCase()); if (v == null) throw notFound("渠道不存在"); return v; } - private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; } + private SailingChannelEntity resolveChannel(SailingData d) { + return resolvePriceBookChannel(d.channelId(), d.warehouseId(), d.channelCode()); + } + private SailingChannelEntity resolvePriceBookChannel(Long channelId, Long warehouseId, String channelCode) { + if (warehouseId == null) throw bad("必须选择收货仓后选择价格表渠道"); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) throw conflict("当前没有生效的船期价格表"); + List rows = priceBooks.channels(versionId, warehouseId); + String normalized = trimUpper(channelCode); + ChannelRow row = rows.stream() + .filter(v -> channelId != null && Objects.equals(v.getChannelCatalogId(), channelId)) + .findFirst() + .orElseGet(() -> rows.stream() + .filter(v -> normalized != null && normalized.equalsIgnoreCase(v.getChannelCode())) + .findFirst().orElse(null)); + if (row == null) throw conflict("渠道必须来自当前生效船期价格表,且在当前收货仓有有效报价"); + return priceBookChannel(row); + } + private SailingChannelEntity resolvePriceBookChannelByCode(Long warehouseId, String channelCode) { + return resolvePriceBookChannel(null, warehouseId, channelCode); + } + private SailingChannelEntity priceBookChannel(ChannelRow row) { + SailingChannelEntity value = new SailingChannelEntity(); + value.setId(row.getChannelCatalogId()); value.setChannelCode(trimUpper(row.getChannelCode())); value.setChannelName(trim(row.getChannelName())); + value.setSourceType("PRICE_BOOK"); value.setDefaultCutoffDays(3); value.setStatus("ACTIVE"); value.setRemark(row.getRemark()); value.setVersion(0); + return value; + } private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; } private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; } private boolean isCopyOf(SailingEntity value, String reason) { -- 2.54.0 From 3f00896411ba35010ad9e2b613187edfb444561b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 04:21:46 +0100 Subject: [PATCH 003/176] test(firstleg): active price book sailing channels --- .../V4__cargo_discard_return_inventory.sql | 7 -- deploy/sql/海外区域统一增量-MySQL8.sql | 4 +- .../sql/海外货物废弃出库字段补齐-MySQL8.sql | 75 --------------- .../out/persistence/PriceBookQueryMapper.java | 22 ++++- .../application/PriceBookQueryService.java | 1 + .../application/model/PriceBookQueryRows.java | 1 + .../port/in/PriceBookQueryUseCase.java | 1 + .../port/out/PriceBookQueryReader.java | 1 + .../dto/SailingCalendarDtos.java | 2 +- .../service/SailingCalendarService.java | 77 ++++++++++----- .../controller/CargoDiscardController.java | 4 +- .../discard/dto/CargoDiscardResponses.java | 4 +- .../entity/CargoDiscardApplication.java | 2 - .../discard/service/CargoDiscardService.java | 96 +------------------ .../lastleg/relabel/dto/RelabelRequests.java | 5 +- .../lastleg/relabel/dto/RelabelResponses.java | 3 +- .../service/RelabelApplicationService.java | 26 +---- .../MerchantReturnOrderController.java | 5 - .../dto/WarehouseTransferRequests.java | 5 +- .../dto/WarehouseTransferResponses.java | 4 +- .../entity/WarehouseTransferOrder.java | 2 - .../mapper/WarehouseTransferOrderMapper.java | 4 +- .../service/WarehouseTransferService.java | 49 +++++----- .../src/main/resources/db/lastleg.sql | 8 +- .../openapi/overseas-business-v1.yaml | 7 -- ...rchantOverseasCustomerApiContractTest.java | 1 - ...chantRelabelApplicationControllerTest.java | 3 +- ...rchantWarehouseTransferControllerTest.java | 3 +- .../service/WarehouseTransferServiceTest.java | 68 ++----------- scripts/db/incremental.sql | 10 +- 30 files changed, 135 insertions(+), 365 deletions(-) delete mode 100644 deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql index 308970df7..ff1784f50 100644 --- a/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql @@ -3,10 +3,6 @@ ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID', MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID'; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='completed_at'), - 'ALTER TABLE ll_cargo_discard_application ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''废弃完成时间'' AFTER reviewed_at', 'SELECT 1'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); @@ -26,6 +22,3 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), - 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4c89ab0b5..176fe0178 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -95,7 +95,7 @@ USE baiyue_lastleg; CREATE TABLE ll_cargo_discard_application ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', outbound_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '废弃出库单号', + discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', inbound_codes varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号集合', @@ -112,7 +112,7 @@ CREATE TABLE ll_cargo_discard_application ( review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', - reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', completed_at datetime(3) DEFAULT NULL COMMENT '废弃完成时间', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), diff --git a/deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql b/deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql deleted file mode 100644 index ab43063ab..000000000 --- a/deploy/sql/海外货物废弃出库字段补齐-MySQL8.sql +++ /dev/null @@ -1,75 +0,0 @@ --- 货物废弃申请 + 废弃出库单字段补齐 --- 目标数据库:baiyue_lastleg --- MySQL 8.0;脚本可重复执行,已存在的字段/索引会跳过。 -USE baiyue_lastleg; - -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id'), - 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID'' AFTER outbound_order_id', - IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND (is_nullable <> 'YES' OR COALESCE(column_comment,'') <> '仓库实物托盘ID')), - 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); -PREPARE outbound_pallet_schema FROM @ddl; EXECUTE outbound_pallet_schema; DEALLOCATE PREPARE outbound_pallet_schema; - - -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), - 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='completed_at'), - 'ALTER TABLE ll_cargo_discard_application ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''废弃完成时间'' AFTER reviewed_at', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; - -SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='order_id' AND is_nullable='NO'), - 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN order_id BIGINT NULL COMMENT ''一件代发订单ID''', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='profile_id' AND is_nullable='NO'), - 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NULL COMMENT ''托盘档案ID''', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_inventory_id' AND is_nullable='NO'), - 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT ''SKU库存记录ID''', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; - -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'), - 'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:DROPSHIP/RETURN_CARTON'' AFTER order_id', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'), - 'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'), - 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'), - 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA状态:PENDING/COMPLETED'' AFTER actual_discard_packages', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'), - 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'), - 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), - 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; - -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), - 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), - 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''收件/业务说明'' AFTER lx_codes', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'), - 'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''请求摘要'' AFTER recipient_info', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; - -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), - 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU''', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks'), - 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''箱唛快照'' AFTER sku_code', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; - --- 退货库存废弃需要允许中间锁定状态;若本地已有约束,按名称重建。 -SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), - 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'); -PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); - -SELECT 'cargo discard/outbound schema patch completed' AS result, DATABASE() AS database_name; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index 7c01a22c5..dbf759f82 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -43,7 +43,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { Long findEffectiveVersionId(@Param("customerId") long customerId); @Select(""" - SELECT c.price_book_version_id AS priceBookVersionId, + SELECT catalog.id AS channelCatalogId, c.price_book_version_id AS priceBookVersionId, c.channel_code AS channelCode, c.channel_name AS channelName, c.price_model AS priceModel, c.estimated_departure_date AS estimatedDepartureDate, @@ -53,6 +53,8 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { c.surcharge_description AS surchargeDescription, c.remark AS remark FROM fl_price_book_channel c + JOIN fl_price_channel_catalog catalog + ON catalog.channel_code = c.channel_code AND catalog.status = 'ACTIVE' WHERE c.price_book_version_id = #{versionId} AND EXISTS ( SELECT 1 FROM fl_carrier_fba_rate r @@ -66,6 +68,24 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { List findChannels(@Param("versionId") long versionId, @Param("originWarehouseId") long originWarehouseId); + @Select(""" + SELECT catalog.id AS channelCatalogId, c.price_book_version_id AS priceBookVersionId, + c.channel_code AS channelCode, c.channel_name AS channelName, + c.price_model AS priceModel, + c.estimated_departure_date AS estimatedDepartureDate, + c.estimated_departure_text AS estimatedDepartureText, + c.latest_warehouse_entry AS latestWarehouseEntry, + c.reference_transit AS referenceTransit, + c.surcharge_description AS surchargeDescription, + c.remark AS remark + FROM fl_price_book_channel c + JOIN fl_price_channel_catalog catalog + ON catalog.channel_code = c.channel_code AND catalog.status = 'ACTIVE' + WHERE c.price_book_version_id = #{versionId} + ORDER BY c.id + """) + List findAllChannels(@Param("versionId") long versionId); + @Select(""" SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode FROM fl_carrier_fba_rate r diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java index e3f07440e..48eceeaaa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -22,6 +22,7 @@ public class PriceBookQueryService implements PriceBookQueryUseCase { @Override public List channels(long versionId, long originWarehouseId) { return reader.findChannels(versionId, originWarehouseId); } + @Override public List allChannels(long versionId) { return reader.findAllChannels(versionId); } @Override public List channelTaxModes(long versionId, long originWarehouseId) { return reader.findChannelTaxModes(versionId, originWarehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java index 0ca9c0b8a..e969cf285 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java @@ -16,6 +16,7 @@ public final class PriceBookQueryRows { @Getter @Setter public static class ChannelRow { + private Long channelCatalogId; private Long priceBookVersionId; private String channelCode; private String channelName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java index 681279a2e..14a3e2d75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -11,6 +11,7 @@ public interface PriceBookQueryUseCase { Long activeVersionId(); Long effectiveVersionId(long customerId); List channels(long versionId, long originWarehouseId); + List allChannels(long versionId); List channelTaxModes(long versionId, long originWarehouseId); List fbaRates(long versionId, long originWarehouseId, String channelCode, String destinationCode, String pricingTaxMode); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java index d12366820..f441f1d68 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -11,6 +11,7 @@ public interface PriceBookQueryReader { Long findActiveVersionId(); Long findEffectiveVersionId(long customerId); List findChannels(long versionId, long originWarehouseId); + List findAllChannels(long versionId); List findChannelTaxModes(long versionId, long originWarehouseId); List findFbaRates(long versionId, long originWarehouseId, String channelCode, String destinationCode, String pricingTaxMode); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java index 4f44ad03d..e626f65ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java @@ -19,7 +19,7 @@ import java.util.List; public final class SailingCalendarDtos { private SailingCalendarDtos() {} - public record ChannelQuery(@Size(max = 64) String keyword, String status) {} + public record ChannelQuery(@Size(max = 64) String keyword, String status, @Positive Long warehouseId) {} public record ChannelData(@NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 128) String channelName, @NotBlank String sourceType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java index 5b3450f46..425510c4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -29,6 +29,8 @@ import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; @@ -41,30 +43,30 @@ public class SailingCalendarService { private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final String OPEN = "OPEN"; @Resource private SailingCalendarRepository repository; + @Resource private PriceBookQueryUseCase priceBooks; public List channels(ChannelQuery query) { - return repository.channels(query).stream().map(v -> new ChannelView(v, "ACTIVE".equals(v.getStatus()))).toList(); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null || "INACTIVE".equalsIgnoreCase(trim(query.status()))) return List.of(); + List rows = query.warehouseId() == null + ? priceBooks.allChannels(versionId) + : priceBooks.channels(versionId, query.warehouseId()); + String keyword = trimUpper(query.keyword()); + return rows.stream() + .filter(row -> !StringUtils.hasText(keyword) + || (row.getChannelCode() != null && row.getChannelCode().toUpperCase().contains(keyword)) + || (row.getChannelName() != null && row.getChannelName().toUpperCase().contains(keyword))) + .map(this::priceBookChannel) + .map(v -> new ChannelView(v, false)) + .toList(); } @Transactional public SailingChannelEntity createChannel(long operatorId, String operatorName, ChannelCreateRequest request) { - if (version(request.expectedVersion()) != 0) throw bad("新建渠道expectedVersion必须为0"); - validateChannel(request.data()); - if (repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在"); - SailingChannelEntity value = new SailingChannelEntity(); value.setId(IdWorker.getId()); - copy(value, request.data()); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); - value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); - try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("渠道代码已存在"); } - return value; + throw conflict("船期渠道来自当前生效船期价格表,不能手工维护"); } @Transactional public SailingChannelEntity updateChannel(long id, long operatorId, String operatorName, ChannelUpdateRequest request) { - SailingChannelEntity before = requiredChannel(id); validateChannel(request.data()); - if (!before.getChannelCode().equalsIgnoreCase(request.data().channelCode().trim()) - && repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在"); - SailingChannelEntity value = new SailingChannelEntity(); value.setId(id); copy(value, request.data()); - value.setStatus(normalizeStatus(request.status(), "渠道状态")); - if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("渠道已被其他人修改"); - return requiredChannel(id); + throw conflict("船期渠道来自当前生效船期价格表,不能手工维护"); } public PageResult templates(TemplateQuery query) { return repository.templates(query); } @@ -73,7 +75,7 @@ public class SailingCalendarService { @Transactional public SailingTemplateEntity createTemplate(long operatorId, String operatorName, TemplateCreateRequest request) { if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0"); - SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel); + SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板"); SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId()); copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); @@ -82,7 +84,7 @@ public class SailingCalendarService { } @Transactional public SailingTemplateEntity updateTemplate(long id, long operatorId, String operatorName, TemplateUpdateRequest request) { - requiredTemplate(id); SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel); + requiredTemplate(id); SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel); SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(id); copy(value, request.data(), channel); value.setStatus(StringUtils.hasText(request.status()) ? normalizeStatus(request.status(), "模板状态") : requiredTemplate(id).getStatus()); if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("模板已被其他人修改"); @@ -196,8 +198,8 @@ public class SailingCalendarService { throw bad("预计体积必须大于0"); } String channelCode = trimUpper(request.channelCode()); - SailingChannelEntity channel = StringUtils.hasText(channelCode) ? repository.channelByCode(channelCode) : null; - if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码"); + SailingChannelEntity channel = StringUtils.hasText(channelCode) ? resolvePriceBookChannelByCode(request.receivingWarehouseId(), channelCode) : null; + if (channel == null) throw bad("预报必须填写当前生效船期价格表中的渠道编码"); String departurePort = trimUpper(request.departurePortCode()); String destinationPort = trimUpper(request.destinationPortCode()); if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode()); @@ -225,8 +227,8 @@ public class SailingCalendarService { if (request.forecastVolumeCbm().signum() <= 0) throw bad("预计体积必须大于0"); String channelCode = trimUpper(request.channelCode()); if (!StringUtils.hasText(channelCode)) channelCode = current.getChannelCode(); - SailingChannelEntity channel = repository.channelByCode(channelCode); - if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码"); + SailingChannelEntity channel = resolvePriceBookChannelByCode(current.getReceivingWarehouseId(), channelCode); + if (channel == null) throw bad("预报必须填写当前生效船期价格表中的渠道编码"); String departurePort = trimUpper(request.departurePortCode()); String destinationPort = trimUpper(request.destinationPortCode()); if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode()); @@ -391,7 +393,7 @@ public class SailingCalendarService { @Transactional public List generate(long operatorId, String operatorName, long templateId, LocalDate from, LocalDate to) { SailingTemplateEntity template = requiredTemplate(templateId); if (!"ACTIVE".equals(template.getStatus())) throw conflict("停用模板不能生成船期"); - SailingChannelEntity channel = requiredChannel(template.getChannelId()); + SailingChannelEntity channel = resolvePriceBookChannel(template.getChannelId(), template.getWarehouseId(), template.getChannelCodeSnapshot()); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能生成船期"); validateTemplateSnapshot(template); List result = new java.util.ArrayList<>(); @@ -473,8 +475,33 @@ public class SailingCalendarService { if (d.etaAt() != null && d.etaAt().isBefore(d.etdAt())) throw bad("ETA不能早于ETD"); if (d.cutoffAt() == null || !d.cutoffAt().isBefore(d.etdAt())) throw bad("收货截止必须早于开船时间"); } - private SailingChannelEntity resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); if (!StringUtils.hasText(d.channelCode())) throw bad("必须选择价格表渠道"); SailingChannelEntity v = repository.channelByCode(d.channelCode().trim().toUpperCase()); if (v == null) throw notFound("渠道不存在"); return v; } - private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; } + private SailingChannelEntity resolveChannel(SailingData d) { + return resolvePriceBookChannel(d.channelId(), d.warehouseId(), d.channelCode()); + } + private SailingChannelEntity resolvePriceBookChannel(Long channelId, Long warehouseId, String channelCode) { + if (warehouseId == null) throw bad("必须选择收货仓后选择价格表渠道"); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) throw conflict("当前没有生效的船期价格表"); + List rows = priceBooks.channels(versionId, warehouseId); + String normalized = trimUpper(channelCode); + ChannelRow row = rows.stream() + .filter(v -> channelId != null && Objects.equals(v.getChannelCatalogId(), channelId)) + .findFirst() + .orElseGet(() -> rows.stream() + .filter(v -> normalized != null && normalized.equalsIgnoreCase(v.getChannelCode())) + .findFirst().orElse(null)); + if (row == null) throw conflict("渠道必须来自当前生效船期价格表,且在当前收货仓有有效报价"); + return priceBookChannel(row); + } + private SailingChannelEntity resolvePriceBookChannelByCode(Long warehouseId, String channelCode) { + return resolvePriceBookChannel(null, warehouseId, channelCode); + } + private SailingChannelEntity priceBookChannel(ChannelRow row) { + SailingChannelEntity value = new SailingChannelEntity(); + value.setId(row.getChannelCatalogId()); value.setChannelCode(trimUpper(row.getChannelCode())); value.setChannelName(trim(row.getChannelName())); + value.setSourceType("PRICE_BOOK"); value.setDefaultCutoffDays(3); value.setStatus("ACTIVE"); value.setRemark(row.getRemark()); value.setVersion(0); + return value; + } private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; } private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; } private boolean isCopyOf(SailingEntity value, String reason) { 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 9ba668cdf..0c6552627 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 @@ -87,9 +87,7 @@ public class CargoDiscardController { private void requireApprovalPermission(AuthenticatedUser user) { if (user == null || user.permissions() == null - || !(user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION) - || user.permissions().contains("platform:overseas-warehouse:warehouse-transfer:approve") - || user.permissions().contains("platform:overseas-warehouse:discard:approve"))) { + || !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required"); } } 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 97098e3ae..1fa1c8e8f 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 @@ -14,10 +14,10 @@ public final class CargoDiscardResponses { List skus) {} public record CandidateSku(String skuCode, String productName, long availablePackages) {} - public record Summary(Long id, String discardNo, String outboundNo, String inboundCodes, + public record Summary(Long id, String discardNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, - String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, LocalDateTime completedAt) {} + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java index 979b8ca67..369ece1f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -14,7 +14,6 @@ public class CargoDiscardApplication { @TableId(type = IdType.INPUT) private Long id; private String customerId; private String discardNo; - private String outboundNo; private String overseasWarehouseId; private String overseasWarehouseName; private String inboundCodes; @@ -30,7 +29,6 @@ public class CargoDiscardApplication { private String reviewedBy; private String reviewRemark; private LocalDateTime reviewedAt; - private LocalDateTime completedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } 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 825a89623..c7d1268ef 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 @@ -16,14 +16,8 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.dropship.entity.DropshipCarton; import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; -import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; @@ -53,8 +47,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.Objects; -import java.util.stream.Collectors; @Service public class CargoDiscardService { @@ -76,9 +68,6 @@ public class CargoDiscardService { @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private OutboundPalletItemMapper outboundPalletItems; - @Resource private DropshipOrderProfileMapper profiles; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ObjectMapper objectMapper; @@ -280,10 +269,6 @@ public class CargoDiscardService { release(customerId, links, applicationItems); } - if (APPROVED.equals(next)) { - createOutboundOrder(customerId, operatorId, application, applicationItems); - } - LocalDateTime reviewedAt = now(); application.setStatus(next); application.setReviewedBy(operatorId); application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt); @@ -292,9 +277,6 @@ public class CargoDiscardService { if (linked != null) { String linkedStatus = mainOrderStatus(next); linked.setCurrentStatus(linkedStatus); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt); - if (APPROVED.equals(next) && StringUtils.hasText(application.getOutboundNo())) { - linked.setOutboundOrderNo(application.getOutboundNo()); - } orders.updateById(linked); LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId()); orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW); @@ -305,68 +287,6 @@ public class CargoDiscardService { return detail(customerId, discardNo); } - private void createOutboundOrder(String customerId, String operatorId, CargoDiscardApplication application, - List applicationItems) { - // Unit tests that exercise the approval state transition without the delivery module - // do not wire these optional persistence collaborators; the running service always does. - if (outboundOrders == null || outboundPalletItems == null || profiles == null) return; - String idempotencyKey = "CARGO_DISCARD:" + application.getId(); - OutboundOrder existing = outboundOrders.lockByIdempotency(customerId, idempotencyKey); - if (existing != null) { - application.setOutboundNo(existing.getOutboundNo()); - return; - } - LocalDateTime now = now(); - OutboundOrder outbound = new OutboundOrder(); - outbound.setId(IdWorker.getId()); - outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("DISCARD_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); - outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); - outbound.setOutboundType("CARGO_DISCARD_OUTBOUND"); - outbound.setStatus("WAIT_PICK"); - outbound.setRecipientInfo("货物废弃:" + application.getDiscardNo()); - outbound.setPackageCount(applicationItems.stream().mapToInt(i -> Objects.requireNonNullElse(i.getDiscardPackages(), 0)).sum()); - outbound.setPalletCount(applicationItems.stream().map(this::physicalKey).collect(Collectors.toSet()).size()); - outbound.setIdempotencyKey(idempotencyKey); - outbound.setCreatedBy(operatorId); - outbound.setCreatedAt(now); - outbound.setUpdatedAt(now); - outboundOrders.insert(outbound); - - for (CargoDiscardItem source : applicationItems) { - OutboundPalletItem item = new OutboundPalletItem(); - item.setId(IdWorker.getId()); - item.setCustomerId(customerId); - item.setOutboundOrderId(outbound.getId()); - item.setPackageCount(Objects.requireNonNullElse(source.getDiscardPackages(), 0)); - item.setPickedPackageCount(0); - item.setStatus("WAIT_PICK"); - item.setSkuCode(source.getSkuCode()); - item.setInboundCode(source.getInboundCode()); - item.setOrderRefs(application.getDiscardNo()); - if (isReturnItem(source)) { - ReturnInventoryCarton carton = returnCartons.selectById(source.getReturnCartonId()); - item.setPalletNo(carton == null ? source.getInboundCode() : firstNonBlank(carton.getPalletCode(), carton.getCartonMark())); - item.setCartonMarks(carton == null ? null : carton.getCartonMark()); - } else { - DropshipOrderProfile profile = profiles.selectById(source.getProfileId()); - if (profile != null) { - item.setWarehousePalletId(profile.getWarehousePalletId()); - item.setPalletNo(firstNonBlank(profile.getPalletNo(), profile.getSourcePalletNo())); - } - } - item.setCreatedAt(now); - outboundPalletItems.insert(item); - } - application.setOutboundNo(outbound.getOutboundNo()); - application.setUpdatedAt(now); - applications.updateById(application); - } - - private String physicalKey(CargoDiscardItem item) { - return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId(); - } - @Transactional public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) { CargoDiscardApplication application = applications.lockNo(customerId, discardNo); @@ -379,9 +299,7 @@ public class CargoDiscardService { public void updateLinkedStatus(String customerId, CargoDiscardApplication application, String from, String to, String operatorId, String remark) { - LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); - if (COMPLETED.equals(to)) application.setCompletedAt(at); - applications.updateById(application); + LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application); LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId()); if (linked != null) { String linkedFrom = mainOrderStatus(from); @@ -502,26 +420,22 @@ public class CargoDiscardService { return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; } - private static String firstNonBlank(String first, String second) { - return first != null && !first.isBlank() ? first : second; - } - private static long value(Long value) { return value == null ? 0L : value; } private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { LastlegOrder order = orders.selectById(orderId); if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); - if (!StringUtils.hasText(order.getOverseasWarehouseId())) { - throw conflict("Cargo discard inventory is not linked to an overseas warehouse"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order"); } return order; } private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { - return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getOutboundNo(), value.getInboundCodes(), + return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(), value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), - value.getReviewedAt(), value.getCompletedAt()); + value.getReviewedAt()); } private void history(String customerId, Long applicationId, String from, String to, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 3947eaf58..4595244e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -122,10 +122,7 @@ public final class RelabelRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, String customerOrderNo, String skuCode, String status, - String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, - LocalDateTime reviewedFrom, LocalDateTime reviewedTo, - LocalDateTime completedFrom, LocalDateTime completedTo, - LocalDateTime outboundFrom, LocalDateTime outboundTo) { + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } 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 db76931eb..9e8d44564 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 @@ -18,8 +18,7 @@ public final class RelabelResponses { String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, - String reviewChannel, String reviewRemark, LocalDateTime reviewedAt, - LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, LocalDateTime createdAt) {} + String reviewChannel, String reviewRemark, LocalDateTime createdAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, String trackingCode, 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 ed620709b..f91799008 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 @@ -502,12 +502,6 @@ public class RelabelApplicationService implements WalletResultHandler { trim(query.overseasWarehouseId())) .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) - .apply(query.reviewedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at >= {0})", query.reviewedFrom()) - .apply(query.reviewedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at <= {0})", query.reviewedTo()) - .apply(query.completedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at >= {0})", query.completedFrom()) - .apply(query.completedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at <= {0})", query.completedTo()) - .apply(query.outboundFrom() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at >= {0})", query.outboundFrom()) - .apply(query.outboundTo() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at <= {0})", query.outboundTo()) .apply(StringUtils.hasText(query.skuCode()), "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", query.skuCode()) @@ -1055,24 +1049,8 @@ public class RelabelApplicationService implements WalletResultHandler { value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER", - null, reviewTime(value), completionTime(value), outboundTime(value), value.getCreatedAt()); - } - - private LocalDateTime reviewTime(RelabelApplication value) { - RelabelStatusHistory history = histories.selectOne(new LambdaQueryWrapper() - .eq(RelabelStatusHistory::getApplicationId, value.getId()).eq(RelabelStatusHistory::getCustomerId, value.getCustomerId()) - .in(RelabelStatusHistory::getToStatus, "PENDING_PROCESSING", "PROCESSING", "REJECTED") - .orderByDesc(RelabelStatusHistory::getCreatedAt).last("LIMIT 1")); - return history == null ? null : history.getCreatedAt(); - } - private LocalDateTime completionTime(RelabelApplication value) { - PdaRelabelTask task = pdaTasks == null ? null : pdaTasks.find(value.getCustomerId(), value.getId()); - return task == null ? null : task.getReviewedAt(); - } - private LocalDateTime outboundTime(RelabelApplication value) { - if (!StringUtils.hasText(value.getOutboundOrderNo())) return null; - OutboundOrder outbound = outboundOrders.findByNo(value.getCustomerId(), value.getOutboundOrderNo()); - return outbound == null ? null : outbound.getCreatedAt(); + null, + value.getCreatedAt()); } @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java index d83bcb4d2..09e870bb6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java @@ -24,11 +24,6 @@ public class MerchantReturnOrderController { @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } - @PutMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") - public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Create request) { - return ApiResponse.success(service.updatePending(customer(user), returnNo, request)); - } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 4e3c3f8ec..42804efba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -168,10 +168,7 @@ public final class WarehouseTransferRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String transferNo, String inboundCode, String operatingWarehouseId, String fbaWarehouseCode, String status, Boolean needRelabel, - LocalDateTime createdFrom, LocalDateTime createdTo, - LocalDateTime reviewedFrom, LocalDateTime reviewedTo, - LocalDateTime measuredFrom, LocalDateTime measuredTo, - LocalDateTime outboundFrom, LocalDateTime outboundTo) { + LocalDateTime createdFrom, LocalDateTime createdTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } } 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 d7334bf94..882719a4c 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 @@ -31,9 +31,7 @@ public final class WarehouseTransferResponses { String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, String relabelApplicationStatus, String relabelFulfillmentStatus, - String status, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt, - LocalDateTime actualShipAt, LocalDateTime reviewedAt, LocalDateTime measuredAt, - LocalDateTime outboundAt, LocalDateTime relabelCompletedAt, LocalDateTime createdAt) {} + String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} 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/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index 2356042cc..a994d1730 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -56,8 +56,6 @@ public class WarehouseTransferOrder { private String outboundIdempotencyKey; private String outboundRequestHash; private LocalDateTime outboundAt; - private LocalDateTime measuredAt; - private LocalDateTime actualShipAt; private String palletizeIdempotencyKey; private String palletizeRequestHash; private String confirmationIdempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index 784e08681..bf0be19c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -20,10 +20,10 @@ import org.apache.ibatis.annotations.Update; WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") java.util.List pdaCandidates(@Param("warehouseId") String warehouseId); - @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},actual_ship_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") + @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("outboundAt") java.time.LocalDateTime outboundAt); - @Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.actual_ship_at=#{outboundAt},wto.updated_at=#{outboundAt} " + + @Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.updated_at=#{outboundAt} " + "WHERE wto.customer_id=#{customerId} AND wto.relabel_no=#{relabelNo} AND wto.outbound_at IS NULL " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_pallet wtp " + "JOIN ll_inventory_position ip ON ip.customer_id=wtp.customer_id AND ip.pallet_id=wtp.warehouse_pallet_id " + 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 30be44835..f4c8b71fe 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 @@ -147,6 +147,12 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); + // A split relabel transfer may intentionally defer the whole-order + // destination: relabeled cartons go through relabel dispatch while the + // remaining cartons are bound later from warehouse-transfer outbound. + // All other transfer types must still select FBA or PRIVATE up front. + validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(), + request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty()); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -266,8 +272,8 @@ public class WarehouseTransferService { WarehouseTransferRequests.Create request) { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if (!Set.of(DRAFT, REJECTED).contains(transfer.getStatus())) { - throw conflict("Only a draft or rejected warehouse transfer order can be edited"); + if (!DRAFT.equals(transfer.getStatus())) { + throw conflict("Only a draft warehouse transfer order can be edited"); } int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); if (itemPackages != request.packageCount()) { @@ -277,6 +283,8 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); + validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(), + request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty()); LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); @@ -312,7 +320,7 @@ public class WarehouseTransferService { order.setVolumeCbm(request.volumeCbm()); order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); order.setCustomerRemark(trim(request.remark())); order.setSourcePayloadHash("sha256:" + requestHash); order.setUpdatedAt(now); @@ -365,10 +373,7 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); - String previousStatus = transfer.getStatus(); - if (!Set.of(DRAFT, REJECTED).contains(previousStatus)) { - throw conflict("Only a draft or rejected warehouse transfer order can be submitted"); - } + if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); @@ -383,13 +388,12 @@ public class WarehouseTransferService { LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode()) ? inboundOrders.selectById(transfer.getInboundOrderId()) : null; - if (inbound != null && customerId.equals(inbound.getCustomerId()) - && Set.of(DRAFT, REJECTED, "CANCELLED").contains(inbound.getStatus())) { + if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) { inbound.setStatus(PENDING_REVIEW); inbound.setUpdatedAt(now); inboundOrders.updateById(inbound); } - insertOrderHistory(customerId, order.getId(), previousStatus, PENDING_REVIEW, transferNo, + insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo, "Warehouse transfer submitted for review by " + operatorId, now); return detail(customerId, transferNo); } @@ -420,12 +424,6 @@ public class WarehouseTransferService { .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) - .ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom()) - .le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo()) - .ge(query.measuredFrom() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredFrom()) - .le(query.measuredTo() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredTo()) - .ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom()) - .le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo()) .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Map relabelApplicationsByNo = relabelApplications(result.getRecords()); @@ -438,9 +436,7 @@ public class WarehouseTransferService { relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(), - value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(), - value.getCreatedAt())).toList(); + value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -858,7 +854,7 @@ public class WarehouseTransferService { completedPalletItems, warehouse, now); completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now); completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now); - transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setMeasuredAt(now); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); + transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size()); @@ -1248,9 +1244,7 @@ public class WarehouseTransferService { relabel == null ? null : relabel.getStatus(), relabel == null ? null : relabel.getFulfillmentStatus(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(), - value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(), - value.getCreatedAt()); + value.getEstimatedArrivalAt(), value.getCreatedAt()); }).toList(); } @@ -1430,6 +1424,15 @@ public class WarehouseTransferService { } } + private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode, + boolean allowDeferredForRelabel) { + String type = trim(destinationType); + if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA"; + if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) { + throw badRequest("仓储转运必须选择 FBA 仓或私人仓"); + } + } + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { String type = trim(request.destinationType()); if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 5e82121fb..a558dba68 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1628,10 +1628,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at')); -PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at')); -PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); @@ -2060,7 +2056,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', @@ -2068,7 +2064,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', - reviewed_at DATETIME(3) NULL COMMENT '审核时间', completed_at DATETIME(3) NULL COMMENT '废弃完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no), UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), 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 cc4c5a626..9f7836de1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1556,13 +1556,6 @@ paths: security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } - put: - summary: 客户编辑待审核退货预报 - description: 权限 merchant:overseas-warehouse:return-order:create;仅允许修改当前客户且仍处于 PENDING_REVIEW 的退货预报,SKU 预报明细会整体替换。 - security: [{ bearerAuth: [] }] - parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } } - responses: { '200': { description: 更新后的退货订单详情 }, '404': { description: 当前客户下退货订单不存在 }, '409': { description: 退货预报已不处于待审核状态 } } /api/merchant/v1/last-leg/return-orders/{returnNo}/photos/files/{fileId}/download: get: summary: 客户下载退货签收打托照片 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index f12eb5cb3..c547cb562 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -85,7 +85,6 @@ class MerchantOverseasCustomerApiContractTest { "cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL)); assertPermissions(MerchantReturnOrderController.class, Map.of( "create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE, - "update", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE, "page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, "detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, "downloadPhoto", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java index ed1a149e5..dce3a6aab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java @@ -17,8 +17,7 @@ class MerchantRelabelApplicationControllerTest { private final RelabelApplicationService service = mock(RelabelApplicationService.class); private final MerchantRelabelApplicationController controller = controller(); private final RelabelRequests.Query query = new RelabelRequests.Query( - 1, 20, null, null, null, null, null, null, null, - null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null); @Test void merchantPageAndDetailUseOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index 91761ec10..ef731e388 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -17,8 +17,7 @@ class MerchantWarehouseTransferControllerTest { private final WarehouseTransferService service = mock(WarehouseTransferService.class); private final MerchantWarehouseTransferController controller = controller(); private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, - null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null); @Test void merchantPageUsesOnlyAuthenticatedTenant() { 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 4fa0efa85..864315768 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 @@ -120,8 +120,7 @@ class WarehouseTransferServiceTest { when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, - null, null, null, null, null, null)); + 1, 20, null, null, null, null, null, null, null, null)); assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus()); @@ -388,25 +387,19 @@ class WarehouseTransferServiceTest { } @Test - void transferAllowsRequestWithoutDestinationForLaterBinding() { + void transferRejectsRequestWithoutDestination() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( "IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5, new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), null, null, null, "FBA destination will be supplied later", false, "key-no-fba", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); - when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); - when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); - when(transferItems.selectList(any())).thenReturn(List.of()); - when(sourceOrders.selectList(any())).thenReturn(List.of()); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request)); - var result = service.create("TENANT-A", "USER-1", request); - - assertEquals(WarehouseTransferService.DRAFT, result.status()); - ArgumentCaptor transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class); - verify(transfers).insert(transfer.capture()); - assertNull(transfer.getValue().getDestinationType()); + assertEquals(400, exception.getStatusCode().value()); + assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason()); + verifyNoInteractions(transfers); } @Test @@ -618,53 +611,6 @@ class WarehouseTransferServiceTest { verify(orderDetails).insert(any(LastlegOrderDetail.class)); } - @Test - void merchantUpdatesRejectedTransferBeforeResubmission() { - WarehouseTransferOrder transfer = persistedTransfer(); - transfer.setStatus(WarehouseTransferService.REJECTED); - LastlegOrder tailOrder = historicalOrder(); - tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED"); - when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); - when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); - when(orders.selectById(31L)).thenReturn(tailOrder); - when(transferItems.selectList(any())).thenReturn(List.of()); - when(sourceOrders.selectList(any())).thenReturn(List.of()); - - var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)); - - assertEquals(WarehouseTransferService.REJECTED, result.status()); - assertTrue(tailOrder.getSubOrderStatus().startsWith("WAREHOUSE_TRANSFER:REJECTED")); - verify(transfers).updateById(transfer); - verify(orders).updateById(tailOrder); - } - - @Test - void merchantResubmitsRejectedTransferForReview() { - WarehouseTransferOrder transfer = persistedTransfer(); - transfer.setStatus(WarehouseTransferService.REJECTED); - transfer.setInboundCode("WTR-1"); - LastlegOrder tailOrder = historicalOrder(); - tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED"); - tailOrder.setRelabelStatus("NOT_REQUIRED"); - LastlegInboundOrder inbound = new LastlegInboundOrder(); - inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW); - when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); - when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); - when(orders.selectById(31L)).thenReturn(tailOrder); - when(inboundOrders.selectById(101L)).thenReturn(inbound); - when(transferItems.selectList(any())).thenReturn(List.of()); - when(sourceOrders.selectList(any())).thenReturn(List.of()); - - var result = service.submit("TENANT-A", "WTR-1", "USER-1"); - - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus()); - var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class); - verify(orderHistories).insert(history.capture()); - assertEquals(WarehouseTransferService.REJECTED, history.getValue().getFromStatus()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, history.getValue().getToStatus()); - } - @Test void merchantCannotUpdateSubmittedTransfer() { WarehouseTransferOrder transfer = persistedTransfer(); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 5b027ece5..74105200f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1369,7 +1369,7 @@ USE baiyue_lastleg; CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', @@ -1493,11 +1493,7 @@ PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLO SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); -PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at')); -PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at')); -PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt; +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -4308,5 +4304,3 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; -SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 2.54.0 From 875d2497f49a82a62cdfe6311e59471c1d04de0f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 04:27:52 +0100 Subject: [PATCH 004/176] docs(firstleg): document price book channel query --- .../src/main/resources/openapi/sailing-calendar-v1.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml b/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml index 5f5cd274d..8cbbea00b 100644 --- a/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml +++ b/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml @@ -67,8 +67,12 @@ paths: responses: {'200': {description: ACTIVE 关联结果}} /api/platform/v1/sailing-channels: get: - summary: 查询船期渠道 + summary: 查询当前生效船期价格表渠道 x-permission: platform:firstleg:sailing-calendar:list + parameters: + - {name: keyword, in: query, schema: {type: string, maxLength: 64}} + - {name: status, in: query, schema: {type: string, enum: [ACTIVE, INACTIVE]}} + - {name: warehouseId, in: query, schema: {type: integer, format: int64, minimum: 1}, description: 按收货仓筛选有有效报价的渠道} responses: {'200': {description: OK}} post: summary: 新增船期渠道 -- 2.54.0 From 8df0f4e823ece83c33798f656714b2998d1136cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 14:18:20 +0800 Subject: [PATCH 005/176] fix(overseas): repair pallet transitions and transfer queries --- .../gateway/HttpPalletPhysicalGateway.java | 20 +++++++++++--- .../service/WarehouseTransferService.java | 6 +++-- .../HttpPalletPhysicalGatewayTest.java | 26 ++++++++++++++++++ .../service/WarehouseTransferServiceTest.java | 17 ++++++++++++ ...overseas-client-services-contract.test.mjs | 2 +- .../phase4-overseas-client-services.mjs | 8 +++--- ...-overseas-pda-operations-contract.test.mjs | 5 ++-- .../phase4-overseas-pda-operations.mjs | 27 +++++++++---------- ...phase4-overseas-wallet-and-pallet-cost.mjs | 11 +++++++- .../WarehousePalletTransitionService.java | 7 ++--- .../WarehousePalletTransitionServiceTest.java | 16 +++++++++++ 11 files changed, 114 insertions(+), 31 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index 0a3472298..a47e6234e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.palletops.gateway; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.config.LastlegRestClientFactory; import com.baiyue.lastleg.config.LastlegRestClientProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -111,18 +113,28 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { .onStatus(HttpStatusCode::isError, (request, result) -> { throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode()); }).body(TransitionEnvelope.class); - if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition"); + requireTransitionSuccess(response); + if (response.data() == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Warehouse returned no transition"); return response.data(); } @Override public void compensate(long warehouseId, Transition transition) { - client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate", + TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate", transition.palletId(), transition.operationKey()) .header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId)) .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode()); - }).toBodilessEntity(); + }).body(TransitionEnvelope.class); + requireTransitionSuccess(response); + } + + private void requireTransitionSuccess(TransitionEnvelope response) { + if (response != null && "200".equals(response.code())) return; + if (response != null && "A0409".equals(response.code())) { + throw new BizException(ResultCode.CONFLICT, response.message()); + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Warehouse pallet transition failed"); } @JsonIgnoreProperties(ignoreUnknown = true) @@ -130,5 +142,5 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { @JsonIgnoreProperties(ignoreUnknown = true) private record LocationEnvelope(LocationReference data) { } @JsonIgnoreProperties(ignoreUnknown = true) - private record TransitionEnvelope(Transition data) { } + private record TransitionEnvelope(String code, String message, Transition data) { } } 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 f4c8b71fe..b4174ee1b 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 @@ -433,8 +433,10 @@ public class WarehouseTransferService { value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), - relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), - relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), + value.getRelabelNo() == null || relabelApplicationsByNo.get(value.getRelabelNo()) == null + ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), + value.getRelabelNo() == null || relabelApplicationsByNo.get(value.getRelabelNo()) == null + ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java index fbed131ed..f7861ca00 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java @@ -26,6 +26,32 @@ class HttpPalletPhysicalGatewayTest { ReflectionTestUtils.setField(gateway, "internalToken", "test-token"); } + @Test + void applyPreservesBusinessConflictInsteadOfReportingMissingTransition() { + server.expect(requestTo("http://warehouse/internal/v1/pda-pallet-transitions/30")) + .andRespond(withSuccess(""" + {"code":"A0409","message":"Pallet version changed","data":null} + """, MediaType.APPLICATION_JSON)); + var pallet = new PalletPhysicalGateway.PalletReference(30L, "PAL-1", 88L, 40L, "IDLE", "XLX1", 2); + assertThatThrownBy(() -> gateway.apply(88L, pallet, "OCCUPIED", 40L, "key", "swap")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("Pallet version changed"); + server.verify(); + } + + @Test + void compensationBusinessFailureIsNotReportedAsSuccess() { + server.expect(requestTo("http://warehouse/internal/v1/pda-pallet-transitions/30/key/compensate")) + .andRespond(withSuccess(""" + {"code":"A0409","message":"Pallet changed after operation","data":null} + """, MediaType.APPLICATION_JSON)); + var transition = new PalletPhysicalGateway.Transition("key", 30L, "PAL-1", 88L, + "IDLE", "OCCUPIED", 40L, 40L, 2, 3, "APPLIED", false); + assertThatThrownBy(() -> gateway.compensate(88L, transition)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + server.verify(); + } + @Test void resolveMapsBusinessNotFoundEnvelopeToStableNotFound() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/MISSING")) 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 864315768..961ab1fd1 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 @@ -127,6 +127,23 @@ class WarehouseTransferServiceTest { assertEquals("READY_FOR_DISPATCH", result.records().get(0).relabelFulfillmentStatus()); } + @Test + void transferPageWithoutRelabelReturnsEmptyRelabelStatuses() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setRelabelNo(null); + Page resultPage = new Page<>(1, 20, 1); + resultPage.setRecords(List.of(transfer)); + when(transfers.selectPage(any(), any())).thenReturn(resultPage); + + var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( + 1, 20, null, null, null, null, null, null, null, null)); + + assertEquals(1, result.records().size()); + assertNull(result.records().get(0).relabelApplicationStatus()); + assertNull(result.records().get(0).relabelFulfillmentStatus()); + verify(relabelApplications, never()).selectList(any()); + } + @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferCartonMapper transferCartons; diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index ab2b8a5ba..d5d99517b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -41,7 +41,7 @@ test('relabel transfer and discard cover create replay conflict list detail and assert.match(source, /query\(`\$\{RELABEL\}\/inventory-candidates`, \{/) for (const token of [ '客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409', - '客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING', + '客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_REVIEW', '客户创建仓储转运申请', '客户仓储转运申请幂等重放', '转运同幂等键异载荷返回A0409', '客户查询自身仓储转运列表', '客户查询自身仓储转运详情', '客户创建货物废弃申请', '客户货物废弃申请幂等重放', diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index bdb071b5c..29231889c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -129,7 +129,7 @@ async function verifyRelabel(context, token, order) { const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body) const relabelNo = id(created.data?.application?.relabelNo) context.assert(MODULE, '客户贴标申请进入合法待处理状态', created.response, - Boolean(relabelNo) && created.data?.application?.status === 'PENDING_PROCESSING' + Boolean(relabelNo) && created.data?.application?.status === 'PENDING_REVIEW' && created.data?.application?.customerOrderNo === order.row.orderNo, `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!relabelNo) return false @@ -145,9 +145,9 @@ async function verifyRelabel(context, token, order) { const detail = await merchant(context, token, 'GET', '客户查询自身贴标申请详情', `${RELABEL}/${encodeURIComponent(relabelNo)}`) context.assert(MODULE, '贴标详情保留明细与创建历史', detail.response, - id(detail.data?.application?.relabelNo) === relabelNo && detail.data?.application?.status === 'PENDING_PROCESSING' + id(detail.data?.application?.relabelNo) === relabelNo && detail.data?.application?.status === 'PENDING_REVIEW' && Array.isArray(detail.data?.items) && detail.data.items.length === 1 - && Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_PROCESSING'), + && Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_REVIEW'), `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo return true @@ -299,7 +299,7 @@ export async function run(context) { const transferDone = await verifyTransfer(context, token, orders) const discardDone = await verifyDiscard(context, token, orders) context.createdResources.phase4OverseasClientServices.finalStatuses = { - relabel: relabelDone ? 'PENDING_PROCESSING' : null, + relabel: relabelDone ? 'PENDING_REVIEW' : null, transfer: transferDone ? 'APPROVED' : null, discard: discardDone ? 'PENDING_REVIEW' : null, } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 71222f3d5..052e5d152 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -94,8 +94,9 @@ test('PDA operations reuse received plan pallets and the public location without /auxiliaryPallets: generatedContainerPallets\.filter/) assert.match(source, /resolvePublicLocation/) assert.match(source, /resolveReusableAuxiliaryPallet/) - assert.match(source, /locationType === 'VIRTUAL'/) - assert.match(source, /P3托盘已在公共库位不重复移库/) + assert.match(source, /\['STORAGE', 'NORMAL'\]\.includes\(item\?\.locationType\)/) + assert.match(source, /status === 'IDLE' && item\?\.locationCode !== sourceLocationCode/) + assert.doesNotMatch(source, /P3托盘已在公共库位不重复移库/) assert.doesNotMatch(source, /warehouse-admin[^'`]*\/locations`,\s*\{/) assert.doesNotMatch(source, /warehouse-admin[^'`]*\/pallets`,\s*\{/) assert.doesNotMatch(source, /createLocation|createEmptyPallet/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 41f2ab370..3b2505e99 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -98,11 +98,11 @@ async function resolvePallet(context, token, palletCode, name = '扫码解析托 '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } -async function resolvePublicLocation(context, fixture) { +async function resolvePublicLocation(context, fixture, sourceLocationCode) { const response = await context.requestOverseas('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`) - const location = records(response?.body?.data).find((item) => item?.locationType === 'VIRTUAL' - && item?.status === 'ACTIVE') + const location = records(response?.body?.data).find((item) => ['STORAGE', 'NORMAL'].includes(item?.locationType) + && item?.status === 'IDLE' && item?.locationCode !== sourceLocationCode) context.addStep(MODULE, '复用当前海外仓公共库位', response, success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode), `locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS) @@ -196,22 +196,14 @@ async function verifyStocktake(context, token, fixture) { } async function verifyInventoryMove(context, token, fixture) { - const target = await resolvePublicLocation(context, fixture) - if (!target) return false let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data if (!pallet || !nonBlank(pallet.locationCode)) { blocker(context, 'P3整托移库', '本轮完成托盘无法解析当前位置') return false } const originalLocationCode = pallet.locationCode - if (originalLocationCode === target.locationCode) { - const availablePackages = (pallet.inventory || []).reduce((sum, item) => - sum + Number(item?.availablePackages || 0), 0) - context.assert(MODULE, 'P3托盘已在公共库位不重复移库', null, - availablePackages > 0, - `palletCode=${pallet.palletCode}, locationCode=${target.locationCode}, availablePackages=${availablePackages}`, OPTIONS) - return availablePackages > 0 - } + const target = await resolvePublicLocation(context, fixture, originalLocationCode) + if (!target) return false const path = '/api/pda/v1/last-leg/inventory-moves' const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode, expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'), @@ -220,6 +212,7 @@ async function verifyInventoryMove(context, token, fixture) { context.assert(MODULE, 'P3整托移库数量和目标位置正确', moved.response, moved.data?.status === 'COMPLETED' && moved.data?.targetLocationCode === target.locationCode && Number(moved.data?.packageCount) > 0, trace(moved.response), OPTIONS) + if (!success(moved.response)) return false const replay = await request(context, token, 'POST', 'P3整托移库同键重放', path, body) context.assert(MODULE, 'P3移库重放标记且结果稳定', replay.response, replay.data?.replayed === true && replay.data?.moveNo === moved.data?.moveNo, trace(replay.response), OPTIONS) @@ -272,6 +265,7 @@ async function verifyPalletOperations(context, token, fixture) { target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) }, reason: `${context.marker}-swap` } const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP') + if (!success(swapped.response)) return false await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`, { ...swapBody, reason: `${swapBody.reason}-DIFFERENT` }) let occupiedSwap = operationPallet(swapped.data, auxiliary.palletCode) @@ -301,6 +295,7 @@ async function verifyPalletOperations(context, token, fixture) { reason: `${context.marker}-disassemble` } const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘', `${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE') + if (!success(disassembled.response)) return false const splitOccupied = operationPallet(disassembled.data, auxiliary.palletCode) || (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托后解析辅助托盘')).data const mainAfterSplit = operationPallet(disassembled.data, main.palletCode) @@ -434,12 +429,16 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const relabelNo = id(created?.application?.relabelNo) context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse, success(createdResponse) && nonBlank(relabelNo) - && created?.application?.status === 'PENDING_PROCESSING', + && created?.application?.status === 'PENDING_REVIEW', `relabelNo=${relabelNo || 'missing'}, sku=${inventory.skuCode}; ${trace(createdResponse)}`, OPTIONS) if (!relabelNo) { blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse) return false } + const reviewed = await request(context, context.overseasToken, 'PUT', 'P5尾程订单审核直接贴标申请', + query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.application.lastlegOrderId))}/relabel-review`, { customerId }), + { status: 'PENDING_PROCESSING', remark: context.marker }) + if (!success(reviewed.response) || reviewed.data?.application?.status !== 'PENDING_PROCESSING') return false const funded = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), (response) => success(response) && response.body?.data?.application?.walletStatus === 'FROZEN') context.addStep(MODULE, 'P5换标费用冻结后开放PDA作业', funded.result, diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index 266d84482..efb735e5e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -244,6 +244,15 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal `relabelNo=${relabelNo}, pollRequests=${frozen.attempts}, walletStatus=${frozen.result?.body?.data?.application?.walletStatus || 'missing'}`) if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null + const reviewStatus = terminalStatus === 'COMPLETED' ? 'PENDING_PROCESSING' : 'CANCELLED' + const review = await context.requestOverseas('PUT', query( + `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.body.data.application.lastlegOrderId))}/relabel-review`, + { customerId: id(context.context.merchantQualificationFixture.tenantId) }), + { status: reviewStatus, remark: context.marker }) + assert(context, `${suffix}尾程订单审核直接贴标申请`, review, + success(review) && review.body.data?.application?.status === reviewStatus) + if (!success(review)) return null + if (terminalStatus === 'COMPLETED') { const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) assert(context, '换标单冻结后才允许进入处理中', processing, @@ -257,7 +266,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal assert(context, '换标客户确认后进入待出库', readyForOutbound, success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND') } - const terminal = await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) + const terminal = terminalStatus === 'CANCELLED' ? review : await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) assert(context, `${suffix}换标单进入${terminalStatus}`, terminal, success(terminal) && terminal.body.data?.application?.status === terminalStatus) const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED' diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java index f4cbe17c2..5c65171cf 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -18,6 +18,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.Objects; import java.util.Set; @Service @@ -102,15 +103,15 @@ public class WarehousePalletTransitionService { WarehouseLocationEntity target; if (sourceLocationId <= targetLocationId) { source = requireLocation(warehouseId, sourceLocationId); - target = sourceLocationId == targetLocationId ? source : requireLocation(warehouseId, targetLocationId); + target = Objects.equals(sourceLocationId, targetLocationId) ? source : requireLocation(warehouseId, targetLocationId); } else { target = requireLocation(warehouseId, targetLocationId); source = requireLocation(warehouseId, sourceLocationId); } requireLocationMatchesPallet(source, pallet.getStatus()); - if (sourceLocationId != targetLocationId) requireAvailableTarget(target, targetStatus); + if (!Objects.equals(sourceLocationId, targetLocationId)) requireAvailableTarget(target, targetStatus); - if (sourceLocationId == targetLocationId) { + if (Objects.equals(sourceLocationId, targetLocationId)) { transitionSameLocation(warehouseId, source, pallet.getStatus(), targetStatus); } else { releaseSourceLocation(warehouseId, source, pallet.getStatus()); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java index 5d37cc44c..a63f0731e 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -48,6 +48,22 @@ class WarehousePalletTransitionServiceTest { assertEquals(4, captor.getValue().getBeforeVersion()); } + @Test void releasesOccupiedPalletAtSameLargeLocationId() { + long locationId = 2104611550538616833L; + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-LARGE", "OCCUPIED", 4, locationId)); + when(mapper.lockPublicLocation(88L, locationId)).thenReturn(location(locationId, "STORAGE", "OCCUPIED")); + when(mapper.transitionLocation(88L, locationId, "OCCUPIED", "IDLE")).thenReturn(1); + when(mapper.transition(88L, 10L, "IDLE", locationId, 4)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "IDLE", locationId, "large-location-release", "swap")); + + assertEquals("IDLE", response.afterStatus()); + assertEquals(locationId, response.afterLocationId()); + verify(mapper, times(1)).lockPublicLocation(88L, locationId); + verify(mapper).transitionLocation(88L, locationId, "OCCUPIED", "IDLE"); + } + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { WarehousePalletOperationEntity existing = operation(); when(mapper.findOperation("operation-1")).thenReturn(existing); -- 2.54.0 From cb8e5fb1450f0f0afa68ca13cd926404aa2916e2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 14:39:11 +0800 Subject: [PATCH 006/176] fix(overseas): preserve shared overflow locations during pallet moves --- .../phase4-overseas-pda-operations.mjs | 16 +++++++++----- .../WarehousePalletTransitionService.java | 14 ++++++------- .../WarehousePalletTransitionServiceTest.java | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 3b2505e99..abcba0290 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -101,8 +101,11 @@ async function resolvePallet(context, token, palletCode, name = '扫码解析托 async function resolvePublicLocation(context, fixture, sourceLocationCode) { const response = await context.requestOverseas('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`) - const location = records(response?.body?.data).find((item) => ['STORAGE', 'NORMAL'].includes(item?.locationType) - && item?.status === 'IDLE' && item?.locationCode !== sourceLocationCode) + const locations = records(response?.body?.data) + const location = locations.find((item) => item?.locationType === 'SHARED_OVERFLOW' + && item?.locationCode === 'A0' && item?.status === 'ACTIVE' && item.locationCode !== sourceLocationCode) + || locations.find((item) => ['STORAGE', 'NORMAL'].includes(item?.locationType) + && item?.status === 'IDLE' && item?.locationCode !== sourceLocationCode) context.addStep(MODULE, '复用当前海外仓公共库位', response, success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode), `locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS) @@ -546,10 +549,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const reworked = await request(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody) if (!success(reworked.response)) return false const reworkReplay = await request(context, context.overseasToken, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) - context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response, - reworkReplay.data?.taskStatus === 'PROCESSING' && Number(reworkReplay.data.relabelledPackages) === 0, + context.assert(MODULE, 'P5 REWORK重放保持AVAILABLE等待重新认领', reworkReplay.response, + reworkReplay.data?.taskStatus === 'AVAILABLE' && Number(reworkReplay.data.relabelledPackages) === 0, trace(reworkReplay.response), OPTIONS) - detail = reworked.data + const reclaimed = await request(context, token, 'POST', 'P5重新认领返工任务', + `${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, { idempotencyKey: key(context, 'RELABEL-REWORK-CLAIM') }) + if (!success(reclaimed.response) || reclaimed.data?.taskStatus !== 'PROCESSING') return false + detail = reclaimed.data for (const redoItem of detail.items || []) { if (redoItem.operationType === 'CARTON_AND_SKU') { const carton = await request(context, token, 'POST', 'P5返工重新确认箱标阶段', diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java index 5c65171cf..7659ae918 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -157,7 +157,7 @@ public class WarehousePalletTransitionService { } private void requireAvailableTarget(WarehouseLocationEntity location, String targetStatus) { - String expected = isVirtual(location) ? "ACTIVE" : "IDLE"; + String expected = isNonExclusive(location) ? "ACTIVE" : "IDLE"; if (!expected.equals(location.getStatus())) { throw new BizException(ResultCode.CONFLICT, "目标库位不可用"); } @@ -168,18 +168,18 @@ public class WarehousePalletTransitionService { private void transitionSameLocation(long warehouseId, WarehouseLocationEntity location, String beforeStatus, String afterStatus) { - if (beforeStatus.equals(afterStatus) || isVirtual(location)) return; + if (beforeStatus.equals(afterStatus) || isNonExclusive(location)) return; changeLocationStatus(warehouseId, location.getId(), expectedLocationStatus(location, beforeStatus), expectedLocationStatus(location, afterStatus)); } private void releaseSourceLocation(long warehouseId, WarehouseLocationEntity source, String palletStatus) { - if (isVirtual(source) || "IDLE".equals(palletStatus)) return; + if (isNonExclusive(source) || "IDLE".equals(palletStatus)) return; changeLocationStatus(warehouseId, source.getId(), "OCCUPIED", "IDLE"); } private void occupyTargetLocation(long warehouseId, WarehouseLocationEntity target, String palletStatus) { - if (isVirtual(target) || "IDLE".equals(palletStatus)) return; + if (isNonExclusive(target) || "IDLE".equals(palletStatus)) return; changeLocationStatus(warehouseId, target.getId(), "IDLE", "OCCUPIED"); } @@ -191,11 +191,11 @@ public class WarehousePalletTransitionService { } private static String expectedLocationStatus(WarehouseLocationEntity location, String palletStatus) { - return isVirtual(location) ? "ACTIVE" : palletStatus; + return isNonExclusive(location) ? "ACTIVE" : palletStatus; } - private static boolean isVirtual(WarehouseLocationEntity location) { - return "VIRTUAL".equals(location.getLocationType()); + private static boolean isNonExclusive(WarehouseLocationEntity location) { + return "VIRTUAL".equals(location.getLocationType()) || WarehouseResourceService.isSharedOverflow(location); } private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java index a63f0731e..4607692d2 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -64,6 +64,27 @@ class WarehousePalletTransitionServiceTest { verify(mapper).transitionLocation(88L, locationId, "OCCUPIED", "IDLE"); } + @Test void movingIntoAndOutOfSharedOverflowKeepsItActive() { + WarehouseLocationEntity overflow = location(20L, "SHARED_OVERFLOW", "ACTIVE"); + overflow.setLocationCode("A0"); overflow.setSharedOverflow(true); + WarehousePalletEntity pallet = pallet("P-1", "OCCUPIED", 4); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED")); + when(mapper.lockPublicLocation(88L, 20L)).thenReturn(overflow); + when(mapper.transitionLocation(88L, 11L, "OCCUPIED", "IDLE")).thenReturn(1); + when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); + service.apply(10L, new PalletTransitionRequests.Apply(88L, 4, "OCCUPIED", 20L, "into-overflow", "move")); + + pallet.setLocationId(20L); pallet.setVersion(5); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "IDLE")); + when(mapper.transitionLocation(88L, 11L, "IDLE", "OCCUPIED")).thenReturn(1); + when(mapper.transition(88L, 10L, "OCCUPIED", 11L, 5)).thenReturn(1); + service.apply(10L, new PalletTransitionRequests.Apply(88L, 5, "OCCUPIED", 11L, "out-of-overflow", "restore")); + + verify(mapper, never()).transitionLocation(eq(88L), eq(20L), anyString(), anyString()); + assertEquals("ACTIVE", overflow.getStatus()); + } + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { WarehousePalletOperationEntity existing = operation(); when(mapper.findOperation("operation-1")).thenReturn(existing); -- 2.54.0 From 60f61d7e0aba170f3b8107d3738c67a7cbd23e73 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 15:02:15 +0800 Subject: [PATCH 007/176] test: verify virtual receiving location rejects pallet restore --- .../phase4-overseas-pda-operations.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index abcba0290..3f3bd98be 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -110,7 +110,8 @@ async function resolvePublicLocation(context, fixture, sourceLocationCode) { success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode), `locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS) return success(response) && location - ? { locationId: id(location.id), locationCode: location.locationCode } : null + ? { locationId: id(location.id), locationCode: location.locationCode, + sourceLocationType: locations.find((item) => item.locationCode === sourceLocationCode)?.locationType } : null } export function remainingRelabelItem(detail, usedItemIds) { @@ -222,7 +223,8 @@ async function verifyInventoryMove(context, token, fixture) { await expectConflict(context, token, 'POST', 'P3移库同键异载荷A0409', path, { ...body, reason: `${body.reason}-DIFFERENT` }) await expectConflict(context, token, 'POST', 'P3移库旧托盘版本A0409', path, { - ...body, targetLocationCode: originalLocationCode, idempotencyKey: key(context, 'MOVE-STALE'), + ...body, expectedPalletVersion: Math.max(0, body.expectedPalletVersion - 1), + idempotencyKey: key(context, 'MOVE-STALE'), }) pallet = (await resolvePallet(context, token, pallet.palletCode, 'P3回查托盘已位于目标库位')).data context.assert(MODULE, 'P3托盘与库存位置同步到目标库位', moved.response, @@ -231,6 +233,15 @@ async function verifyInventoryMove(context, token, fixture) { const restoreBody = { sourcePalletCode: pallet.palletCode, targetLocationCode: originalLocationCode, expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-RESTORE'), reason: `${context.marker}-move-restore` } + if (target.sourceLocationType === 'VIRTUAL') { + const rejected = await expectConflict(context, token, 'POST', 'P3禁止回移到虚拟收货位', path, restoreBody) + const retained = (await resolvePallet(context, token, pallet.palletCode, 'P3拒绝回移后仍在上架库位')).data + context.assert(MODULE, 'P3虚拟位回移失败不改变托盘和库存', rejected, + conflict(rejected) && retained?.locationCode === target.locationCode + && Number(retained?.palletVersion) === Number(pallet.palletVersion) + && Number(retained?.totalPackages) === Number(pallet.totalPackages), trace(rejected), OPTIONS) + return conflict(rejected) && retained?.locationCode === target.locationCode + } const restored = await request(context, token, 'POST', 'P3整托移回原库位供后续验证', path, restoreBody) return success(restored.response) && restored.data?.targetLocationCode === originalLocationCode } -- 2.54.0 From 41d8387cd50d51c530b89aba0218531280cad92f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 15:06:42 +0800 Subject: [PATCH 008/176] test: align acceptance contracts with current initialization and service clients --- ...t-prototype-field-matrix-contract.test.mjs | 2 +- ...merged-overseas-coverage-contract.test.mjs | 2 +- ...se3-container-regression-contract.test.mjs | 2 +- scripts/overseas-deployment-contract.test.mjs | 34 +++++++++---------- scripts/synchronous-dependency-baseline.mjs | 8 +++++ .../synchronous-dependency-contract.test.mjs | 9 ++--- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 059d2c520..dfc1239ed 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -123,7 +123,7 @@ test('字段断言接入正式生产模块和默认全量入口', () => { }) test('矩阵登记 LANDED 与 UNKNOWN 且禁止 fixture 伪造未知字段', () => { - const matrix = read(workspace, 'docs', '国内货柜履约', '货柜履约原型字段验收矩阵-2026-08-17.md') + const matrix = read(workspace, 'docs', '国内货柜履约', '归档', '货柜履约原型字段验收矩阵-2026-08-17.md') assert.match(matrix, /LANDED/) assert.match(matrix, /UNKNOWN/) assert.match(matrix, /不得由 fixture 伪造|不生成假值/) diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs index b271ee44a..ccbe7dbb7 100644 --- a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -28,7 +28,7 @@ test('transfer review integration covers both source types and known blank-reaso const source = await read('warehouse-transfer-review.mjs') const caller = await read('phase4-overseas-client-services.mjs') assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/) - for (const value of ['WAIT_PDA_RECEIVING', 'PENDING_ASSIGNMENT', 'WAIT_DELIVERY', 'CANCELLED', + for (const value of ['WAIT_PDA_RECEIVING', 'INBOUND_MEASURING', 'CANCELLED', 'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)', 'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"]) assert.ok(source.includes(value), value) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index ff083903f..211ec91ec 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -22,7 +22,7 @@ const containerOverviewPanel = read(`${adminRoot}container-loading/components/Co const containerLoadingAcceptance = read('phase3-container-loading.mjs') const phase3LoadingAcceptance = read('phase3-loading.mjs') const truckAcceptance = read('truck-appointments.mjs') -const matrix = read('../../../docs/国内货柜履约/货柜管理全链验收矩阵-2026-08-13.md') +const matrix = read('../../../docs/国内货柜履约/归档/货柜管理全链验收矩阵-2026-08-13.md') test('container acceptance reads the canonical domestic workbench route', () => { assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 33ad829cf..4f294563b 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -152,16 +152,17 @@ test('domestic Auth receives the overseas JWT signing configuration', async () = ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) }) -test('domestic event bridge SQL converges legacy tenant columns to the customer entity contract', async () => { +test('domestic event bridge initialization defines customer columns without legacy migration', async () => { const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) - assert.equal((incremental.slice(0, incremental.indexOf('CREATE TABLE IF NOT EXISTS bridge_delivery_attempt')) - .match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) - assert.match(incremental, /ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id/) - assert.match(incremental, /ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id/) - assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) - assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) + for (const table of ['bridge_inbox', 'bridge_outbox']) { + const definition = incremental.match(new RegExp(`CREATE TABLE ${table} \\(([\\s\\S]*?)\\) ENGINE`))?.[1] + assert.ok(definition, `${table} must be defined by the empty-database baseline`) + assert.match(definition, /customer_id varchar\(128\)[^\n]*NOT NULL/i) + assert.doesNotMatch(definition, /\btenant_id\b/) + } + assert.doesNotMatch(incremental, /RENAME COLUMN tenant_id TO customer_id/) }) test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { @@ -176,15 +177,12 @@ test('both overseas PDF consumers explicitly connect to the existing CN Print se } }) -test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => { +test('domestic initialization defines the overseas hold version and comment in the table', async () => { const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();') - const queueComment = incremental.indexOf("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version'") - - assert.notEqual(addColumn, -1) - assert.notEqual(queueComment, -1) - assert.ok(addColumn < queueComment, - 'existing databases must add last_overseas_hold_version before the strict comment queue reads it') + const definition = incremental.match(/CREATE TABLE fl_inbound_order \(([\s\S]*?)\) ENGINE/)?.[1] + assert.ok(definition) + assert.match(definition, /last_overseas_hold_version bigint unsigned DEFAULT NULL COMMENT '[^']+'/i) + assert.doesNotMatch(incremental, /CALL (?:add_inbound_overseas_hold_version|queue_column_comment)\(/) }) test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { @@ -192,15 +190,15 @@ test('overseas unified SQL initializes the isolated Event Bridge database before assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) assert.match(overseas, /USE baiyue_event_bridge_overseas;/) for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { - assert.match(overseas, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) + assert.match(overseas, new RegExp(`CREATE TABLE ${table} \\(`)) } for (const index of [ 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', 'uk_bridge_attempt', 'idx_bridge_attempt_event' ]) assert.match(overseas, new RegExp(index)) - assert.match(overseas, /COUNT\(DISTINCT CONCAT\(table_name, ':', index_name\)\)/) - assert.match(overseas, /CALL assert_overseas_event_bridge_schema\(\);/) + assert.doesNotMatch(overseas, /CREATE TABLE IF NOT EXISTS/) + assert.match(overseas, /initialization_status/) assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), 'overseas Event Bridge schema must be initialized before Lastleg tables') }) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 1c7845f0f..977e42208 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,14 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '按作业仓与账号查询有效国内 PDA 操作人,内部服务令牌保护'), + feign('order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '历史订单导入按客户与始发仓只读匹配渠道'), + feign('order/src/main/java/com/baiyue/order/client/LegacyImportWarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '历史订单导入只读匹配仓库目录'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '转仓测量照片上传无幂等键,元数据与下载按客户、业务类型和转仓单校验归属'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java', 'lastleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '根据已授权排车快照生成 BOL PDF,内部令牌保护,无业务状态写入'), feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index bb70439d8..d94f07c7c 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -101,15 +101,15 @@ async function discoverFeignEdges() { return clients.sort((left, right) => left.path.localeCompare(right.path)) } -test('all 60 Feign clients have reviewed context, access and idempotency metadata', async () => { +test('all Feign clients have reviewed context, access and idempotency metadata', async () => { 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, 60) + assert.equal(reviewed.length, 63) assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('all 23 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { +test('all raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -121,7 +121,7 @@ test('all 23 raw HTTP transports have reviewed context, access and idempotency m } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 23) + assert.equal(reviewed.length, 24) assert.deepEqual(discovered.sort(), reviewed) }) @@ -224,6 +224,7 @@ test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => { 'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', 'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', 'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java', 'order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.java' ]) assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [ -- 2.54.0 From 54ad26c60748b22381c80b8697923c858532b062 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 17:55:54 +0800 Subject: [PATCH 009/176] =?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 | 9 +- README.md | 8 +- auth/AGENTS.md | 8 +- .../auth/config/AuthSecurityProperties.java | 2 +- .../auth/config/DomesticJwtConfiguration.java | 22 -- .../auth/config/OverseasJwtProperties.java | 10 - ...tion.java => UnifiedJwtConfiguration.java} | 18 +- .../auth/config/UnifiedJwtProperties.java | 7 + .../auth/controller/AuthController.java | 15 -- .../auth/controller/JwksController.java | 12 +- .../com/baiyue/auth/dto/TokenResponse.java | 4 - .../com/baiyue/auth/service/AuthService.java | 19 +- .../auth/service/OverseasTokenService.java | 71 ------ auth/src/main/resources/application.yml | 14 +- .../config/DualTrackJwtConfigurationTest.java | 29 --- .../config/UnifiedJwtConfigurationTest.java | 43 ++++ .../auth/controller/JwksControllerTest.java | 5 +- .../auth/service/AuthServiceSmallJwtTest.java | 36 +-- .../service/OverseasTokenServiceTest.java | 139 ------------ .../common/core/retry/BoundedRetryPolicy.java | 31 +++ .../common/core/BoundedRetryPolicyTest.java | 31 +++ .../com/baiyue/common/event/DeliveryAck.java | 31 +++ .../common/event/EventDeliveryRoute.java | 23 ++ .../baiyue/common/event/DeliveryAckTest.java | 50 +++++ common-security/AGENTS.md | 8 +- .../BaiyueSecurityAutoConfiguration.java | 15 +- .../ClaimsAccessTokenSessionValidator.java | 16 -- .../GatewayAccessTokenSessionValidator.java | 65 ------ .../HmacJwtDecoderAutoConfiguration.java | 33 --- .../JwksJwtDecoderAutoConfiguration.java | 6 +- .../security/JwtAuthenticationFilter.java | 4 - ...ot.autoconfigure.AutoConfiguration.imports | 1 - ...atewayAccessTokenSessionValidatorTest.java | 49 ----- .../security/JwtAuthenticationFilterTest.java | 23 +- .../security/UnifiedSessionModeTest.java | 22 ++ .../web/EventDeliveryFeignConfiguration.java | 33 +++ .../EventDeliveryFeignConfigurationTest.java | 49 +++++ config/nacos/local/lastleg-service.yml | 4 +- .../local/warehouse-overseas-service.yml | 4 +- finance/pom.xml | 1 + .../client/WalletResultEventBridgeClient.java | 18 -- .../client/WalletResultLastlegClient.java | 19 ++ .../InternalBusinessFeeSourceController.java | 4 +- .../InternalWalletCommandEventController.java | 4 +- .../BusinessFeeSourceEventService.java | 7 +- .../service/WalletCommandEventService.java | 85 +++++--- .../service/WalletResultOutboxRelay.java | 53 +++-- .../BusinessFeeSourceEventServiceTest.java | 10 +- .../WalletCommandEventServiceTest.java | 53 +++-- .../finance/WalletCommandTransactionTest.java | 205 ++++++++++++++++++ .../finance/WalletResultOutboxLeaseTest.java | 75 +++++++ .../baiyue/finance/WalletResultRetryTest.java | 86 ++++++++ firstleg/AGENTS.md | 4 +- .../firstleg/client/EventBridgeClient.java | 19 -- .../firstleg/client/LastlegEventClient.java | 20 ++ ...ernalOverseasContainerEventController.java | 14 +- .../LoadingCompletedOutboxRepository.java | 9 + .../service/LoadingCompletedOutboxRelay.java | 46 ++-- .../OverseasContainerEventConsumer.java | 16 +- .../OverseasDeliveryCompletedConsumer.java | 13 +- .../OverseasInboundOrderDetainedConsumer.java | 17 +- .../LoadingCompletedOutboxRelayTest.java | 54 ++++- .../OverseasContainerEventConsumerTest.java | 16 +- ...rseasInboundOrderDetainedConsumerTest.java | 6 +- .../gateway/config/GatewaySecurityConfig.java | 19 +- .../config/GatewaySecurityProperties.java | 3 +- gateway/src/main/resources/application.yml | 30 ++- ...merTransitStatisticsRouteContractTest.java | 2 +- .../config/GatewayCorsConfigurationTest.java | 2 +- .../GatewayFileRouteConfigurationTest.java | 2 +- .../gateway/config/UnifiedJwtDecoderTest.java | 55 +++++ .../iam/mapper/AuthorizationQueryMapper.java | 4 + .../PlatformUserManagementService.java | 2 +- .../com/baiyue/iam/service/TenantService.java | 20 ++ .../mapper/AuthorizationIdentitySqlTest.java | 37 ++++ .../PlatformUserManagementServiceTest.java | 12 + .../baiyue/iam/service/TenantServiceTest.java | 24 ++ lastleg-service/pom.xml | 1 + .../lastleg/client/DirectEventClient.java | 28 +++ .../lastleg/client/EventBridgeClient.java | 20 -- .../client/FinanceBusinessEventClient.java | 21 ++ .../lastleg/client/FirstlegEventClient.java | 18 ++ .../controller/LastlegOrderController.java | 5 +- .../OverseasFeeOutboxRepository.java | 11 +- .../service/OverseasFeeOutboxRelay.java | 39 +++- .../InboundUnloadCompletedOutboxRelay.java | 46 ++-- .../UnloadCompletedOutboxRepository.java | 9 + .../service/LoadingCompletedConsumer.java | 10 +- .../ContainerShippingNodeConsumer.java | 16 +- .../WalletCommandCompletedConsumer.java | 17 +- .../src/main/resources/application.yml | 5 +- .../lastleg/client/DirectEventClientTest.java | 77 +++++++ .../client/EventBridgeClientContractTest.java | 16 -- .../OverseasFeeOutboxRetryTest.java | 35 +++ ...InboundUnloadCompletedOutboxRelayTest.java | 67 +++++- .../repository/OutboxLeaseDatabaseTest.java | 56 +++++ .../UnloadCompletedOutboxRepositoryTest.java | 13 ++ .../service/LoadingCompletedConsumerTest.java | 22 +- .../TaillegRealConsumerAcceptanceTest.java | 2 +- .../ContainerShippingNodeConsumerTest.java | 6 +- scripts/agents-index-contract.ps1 | 12 +- scripts/gateway-route-contract.test.mjs | 4 +- .../PlatformWarehouseDirectoryController.java | 38 +--- .../PlatformWarehouseResourceController.java | 2 +- .../controller/WarehouseHealthController.java | 26 --- .../src/main/resources/application.yml | 4 +- .../resources/openapi/pallet-label-v1.yaml | 2 +- ...tformWarehouseDirectoryControllerTest.java | 33 --- ...rehouseResourcePermissionContractTest.java | 6 +- ...ormOverseasReceivingOptionsController.java | 51 +++++ ...verseasReceivingOptionsControllerTest.java | 46 ++++ 111 files changed, 1837 insertions(+), 962 deletions(-) delete mode 100644 auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java delete mode 100644 auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java rename auth/src/main/java/com/baiyue/auth/config/{OverseasJwtConfiguration.java => UnifiedJwtConfiguration.java} (67%) create mode 100644 auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java delete mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java delete mode 100644 auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java delete mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java delete mode 100644 common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java delete mode 100644 common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java delete mode 100644 common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java delete mode 100644 common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java create mode 100644 common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java delete mode 100644 finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java create mode 100644 finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java create mode 100644 finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java diff --git a/AGENTS.md b/AGENTS.md index 57fb7d324..df742e218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # 头程与海外仓后端项目指引 +> 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。 + ## 适用范围与定位 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 @@ -95,10 +97,9 @@ ## 国内头程与海外仓协同 -- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 -- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 Gateway 中转。 -- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 -- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 +- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。原双 Token 与 Lastleg 安全投影在替代撤销链验证后移除。 +- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;现有 Bridge 尚未退役,不得先停服务后补替代链。 +- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 ## 契约、数据库与验证入口 diff --git a/README.md b/README.md index aaebd9eea..cac61cb47 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 柏悦头程平台 -本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 +本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。 + +整体迁移尚未完成:下面模块清单与旧启动配置描述的是现有源码,海外 Gateway、事件桥和双 Token 仍待替代;不代表需要继续规划双区域或其他环境。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 @@ -29,7 +31,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 +现有本地启动入口包含 IAM、Auth、Firstleg、Order、Warehouse、File、Print 和 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。旧海外 Compose/Nacos 仍存在;新的测试启动依赖和统一配置按部署主契约收敛,本地编译不代表另有开发环境。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 @@ -61,4 +63,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 0cb3466c8..195b1036b 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -5,7 +5,7 @@ - Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 -- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 +- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/统一认证与服务通信契约.md`。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 @@ -16,8 +16,8 @@ | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | | 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | -| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | -| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 | +| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 | +| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 | | 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | @@ -28,7 +28,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java index 5b9f00a1c..32ad3cbb0 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java @@ -8,7 +8,7 @@ import java.time.Duration; * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record AuthSecurityProperties(String jwtSecret, String issuer, String audience, +public record AuthSecurityProperties(String issuer, String audience, Duration accessTokenTtl, Duration refreshTokenTtl, int maxLoginFailures, Duration lockDuration) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java deleted file mode 100644 index acae75153..000000000 --- a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.auth.config; - -import com.nimbusds.jose.jwk.source.ImmutableSecret; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; - -import java.nio.charset.StandardCharsets; - -/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */ -@Configuration(proxyBeanMethods = false) -public class DomesticJwtConfiguration { - @Bean("jwtEncoder") - @Primary - JwtEncoder jwtEncoder(AuthSecurityProperties properties) { - byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - return new NimbusJwtEncoder(new ImmutableSecret<>(secret)); - } -} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java deleted file mode 100644 index 62e173c7f..000000000 --- a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.auth.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.time.Duration; - -/** 海外尾程 RS256 短时令牌配置,默认关闭。 */ -@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt") -public record OverseasJwtProperties(boolean enabled, String issuer, String audience, - String rsaJwkSet, String currentKid, Duration accessTokenTtl) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java similarity index 67% rename from auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java rename to auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java index ed7d60faa..37c346b4d 100644 --- a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java +++ b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java @@ -3,7 +3,6 @@ package com.baiyue.auth.config; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,27 +10,26 @@ import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(OverseasJwtProperties.class) -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") -public class OverseasJwtConfiguration { - @Bean("overseasJwtEncoder") - JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) { +@EnableConfigurationProperties(UnifiedJwtProperties.class) +public class UnifiedJwtConfiguration { + @Bean("jwtEncoder") + JwtEncoder jwtEncoder(UnifiedJwtProperties properties) { try { JWKSet set = JWKSet.parse(properties.rsaJwkSet()); RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null; if (current == null || !current.isPrivate()) { - throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥"); + throw new IllegalStateException("统一 current-kid 必须指向包含私钥的 RSA 密钥"); } if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) { - throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥"); + throw new IllegalStateException("统一 JWK Set 只能包含 RSA 密钥"); } if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID()) && jwk instanceof RSAKey rsa && rsa.isPrivate())) { - throw new IllegalStateException("海外非当前密钥只能配置公钥"); + throw new IllegalStateException("统一非当前密钥只能配置公钥"); } return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); } catch (java.text.ParseException exception) { - throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception); + throw new IllegalStateException("统一 RSA JWK Set 配置无效", exception); } } } diff --git a/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java new file mode 100644 index 000000000..265f9d4a2 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java @@ -0,0 +1,7 @@ +package com.baiyue.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Unified RS256 signing key configuration. Private keys remain in Auth. */ +@ConfigurationProperties(prefix = "baiyue.security.jwt") +public record UnifiedJwtProperties(String rsaJwkSet, String currentKid) { } diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 6130bf029..a4fd65b75 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -23,8 +23,6 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos; import com.baiyue.auth.service.AccountManagementService; -import com.baiyue.auth.service.OverseasTokenService; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.access.prepost.PreAuthorize; /** @@ -40,19 +38,6 @@ public class AuthController { private MerchantRegistrationService registrationService; @Resource private AccountManagementService accountManagementService; - @Resource - private ObjectProvider overseasTokens; - - @PostMapping("/overseas/token") - @PreAuthorize("isAuthenticated()") - @Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。") - public ApiResponse exchangeOverseasToken( - @AuthenticationPrincipal AuthenticatedUser user) { - OverseasTokenService service = overseasTokens.getIfAvailable(); - if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用"); - return ApiResponse.success(service.exchange(user.userId())); - } - @PutMapping("/password") @PreAuthorize("isAuthenticated()") @Operation(summary = "修改当前用户密码") diff --git a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java index f8fa9487e..136712e26 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -1,10 +1,9 @@ package com.baiyue.auth.controller; -import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; import com.nimbusds.jose.jwk.JWKSet; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirements; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -13,17 +12,16 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.concurrent.TimeUnit; -/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */ +/** 发布统一 JWT 的 RSA 公钥。 */ @RestController -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") public class JwksController { - private final OverseasJwtProperties properties; + private final UnifiedJwtProperties properties; - public JwksController(OverseasJwtProperties properties) { this.properties = properties; } + public JwksController(UnifiedJwtProperties properties) { this.properties = properties; } @GetMapping("/api/auth/.well-known/jwks.json") @SecurityRequirements - @Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") + @Operation(summary = "获取统一 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") public ResponseEntity> jwks() throws Exception { return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) .body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject()); diff --git a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java index aa86d983c..9d5af3cc9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -14,10 +14,6 @@ public record TokenResponse( @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn, - @Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", - format = "password", nullable = true) String overseasAccessToken, - @Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0", - example = "900", minimum = "0") long overseasExpiresIn, @Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set roles, @Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set authorities ) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 15144bf9f..d3b76d480 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -16,14 +16,14 @@ import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.beans.factory.ObjectProvider; +import com.baiyue.auth.config.UnifiedJwtProperties; import java.security.SecureRandom; import java.time.Instant; @@ -51,7 +51,7 @@ public class AuthService { @Resource private TokenSessionStore tokenSessionStore; @Resource - private ObjectProvider overseasTokens; + private UnifiedJwtProperties signingProperties; private final SecureRandom secureRandom = new SecureRandom(); @@ -147,13 +147,8 @@ public class AuthService { String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); - OverseasTokenService service = overseasTokens.getIfAvailable(); - OverseasTokenService.OverseasTokenResponse overseas = service == null - ? null : service.issue(account, authorization); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(), - overseas == null ? null : overseas.accessToken(), - overseas == null ? 0L : overseas.expiresIn(), authorization.getRoles(), authorization.getPermissions()); } @@ -208,17 +203,11 @@ public class AuthService { String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), Duration.ofMinutes(15)); - OverseasTokenService service = overseasTokens.getIfAvailable(); - OverseasTokenService.OverseasTokenResponse overseas = service == null - ? null : service.issue(account, authorization); return new ImpersonationTokenResponse("Bearer", accessToken, 900, - overseas == null ? null : overseas.accessToken(), - overseas == null ? 0L : overseas.expiresIn(), authorization.getRoles(), authorization.getPermissions()); } public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn, - String overseasAccessToken, long overseasExpiresIn, java.util.Set roles, java.util.Set authorities) { public ImpersonationTokenResponse { @@ -228,7 +217,7 @@ public class AuthService { } private JwsHeader signingHeader() { - return JwsHeader.with(MacAlgorithm.HS256).build(); + return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build(); } private static BizException invalidCredentials() { diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java deleted file mode 100644 index 5c90d612e..000000000 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.config.OverseasJwtProperties; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.JwtClaimsSet; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.security.oauth2.jwt.JwsHeader; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ -@Service -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") -public class OverseasTokenService { - @Resource private AuthAccountRepository accounts; - @Resource private AuthorizationLoader authorizationLoader; - @Resource private OverseasJwtProperties properties; - @Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder; - - public OverseasTokenResponse exchange(long authenticatedUserId) { - AuthAccount account = accounts.findById(authenticatedUserId); - if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { - throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); - } - AuthorizationSnapshot authorization = authorizationLoader.load(account); - return issue(account, authorization); - } - - public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { - Instant issuedAt = Instant.now(); - Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() - .issuer(properties.issuer()).audience(List.of(properties.audience())) - .subject(account.getId().toString()).id(UUID.randomUUID().toString()) - .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) - .claim("membership_id", authorization.getMembershipId()) - .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) - .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); - if (authorization.getOverseasWarehouseId() != null) { - claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); - } - JwtClaimsSet claims = claimsBuilder.build(); - String token = encoder.encode(JwtEncoderParameters.from( - JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); - return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(), - authorization.getRoles(), authorization.getPermissions()); - } - - public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn, - java.util.Set roles, java.util.Set authorities) { - public OverseasTokenResponse { - roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles); - authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities); - } - } -} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index fd346398e..ad9451d45 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -30,7 +30,6 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 baiyue.security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} @@ -39,13 +38,12 @@ baiyue.security: refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} max-login-failures: ${MAX_LOGIN_FAILURES:5} lock-duration: ${LOGIN_LOCK_DURATION:15m} - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:false} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} + jwt-mode: jwks + session-mode: redis + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwt: + rsa-jwk-set: ${JWT_RSA_JWK_SET:} + current-kid: ${JWT_CURRENT_KID:} baiyue.clients: iam: diff --git a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java deleted file mode 100644 index 65c871721..000000000 --- a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.baiyue.auth.config; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.security.oauth2.jwt.JwtEncoder; - -import static org.assertj.core.api.Assertions.assertThat; - -class DualTrackJwtConfigurationTest { - private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class)) - .withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties( - "domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api", - java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5, - java.time.Duration.ofMinutes(15))) - .withPropertyValues( - "baiyue.security.overseas-jwt.enabled=false"); - - @Test - void startsDomesticEncoderWithoutRsaConfiguration() { - runner.run(context -> { - assertThat(context).hasNotFailed(); - assertThat(context).hasBean("jwtEncoder"); - assertThat(context).doesNotHaveBean("overseasJwtEncoder"); - assertThat(context).hasSingleBean(JwtEncoder.class); - }); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java new file mode 100644 index 000000000..8c4f1c416 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java @@ -0,0 +1,43 @@ +package com.baiyue.auth.config; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class UnifiedJwtConfigurationTest { + @Test + void signsWithCurrentKeyAndRetainsPreviousPublicKeyForRotation() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").algorithm(JWSAlgorithm.RS256).generate(); + var previous = new RSAKeyGenerator(2048).keyID("previous").algorithm(JWSAlgorithm.RS256).generate(); + var properties = new UnifiedJwtProperties(new JWKSet(List.of(current, previous.toPublicJWK())).toString(false), "current"); + var encoder = new UnifiedJwtConfiguration().jwtEncoder(properties); + var now = Instant.now(); + var token = encoder.encode(JwtEncoderParameters.from( + JwsHeader.with(SignatureAlgorithm.RS256).keyId("current").build(), + JwtClaimsSet.builder().subject("42").issuedAt(now).expiresAt(now.plusSeconds(60)).build())); + var decoded = NimbusJwtDecoder.withPublicKey(current.toRSAPublicKey()).build().decode(token.getTokenValue()); + assertThat(decoded.getSubject()).isEqualTo("42"); + assertThat(decoded.getHeaders()).containsEntry("kid", "current").containsEntry("alg", "RS256"); + assertThatThrownBy(() -> NimbusJwtDecoder.withPublicKey(previous.toRSAPublicKey()).build().decode(token.getTokenValue())) + .isInstanceOf(JwtException.class); + } + + @Test + void rejectsMissingPrivateKeyAndPreviousPrivateKey() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").generate(); + var previous = new RSAKeyGenerator(2048).keyID("previous").generate(); + var config = new UnifiedJwtConfiguration(); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(current.toPublicJWK()).toString(), "current"))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(List.of(current, previous)).toString(false), "current"))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties("", "current"))) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java index 53a673c72..9feacbcc7 100644 --- a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java +++ b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java @@ -1,6 +1,6 @@ package com.baiyue.auth.controller; -import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.KeyUse; @@ -20,8 +20,7 @@ class JwksControllerTest { RSAKey current = key("current", true); RSAKey previous = key("previous", false); String json = new JWKSet(List.of(current, previous)).toString(false); - var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json, - "current", Duration.ofMinutes(15)); + var properties = new UnifiedJwtProperties(json, "current"); var body = new JwksController(properties).jwks().getBody(); String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 1ea5b0459..f908f1ab3 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -12,7 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; @@ -37,21 +37,24 @@ class AuthServiceSmallJwtTest { @MockBean private JwtEncoder jwtEncoder; @MockBean private AuthorizationLoader authorizationLoader; @MockBean private TokenSessionStore tokenSessionStore; - @MockBean private OverseasTokenService overseasTokenService; @Resource private AuthService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @Bean + com.baiyue.auth.config.UnifiedJwtProperties signingProperties() { + return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current"); + } @Bean AuthSecurityProperties properties() { - return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", + return new AuthSecurityProperties("auth-service", "lastleg-api", Duration.ofMinutes(30), Duration.ofDays(7), 5, Duration.ofMinutes(10)); } } @Test - void signsCrossRegionClaimsAndStoresSession() { + void signsUnifiedClaimsAndStoresSession() { AuthAccount account = new AuthAccount(); account.setId(42L); account.setUsername("operator"); @@ -71,9 +74,6 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-token"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900, - snapshot.getRoles(), snapshot.getPermissions())); var response = service.login(new LoginRequest("operator", "secret")); @@ -89,16 +89,13 @@ class AuthServiceSmallJwtTest { .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); - assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); - assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); + assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); + assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current"); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); - verify(overseasTokenService).issue(account, snapshot); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); assertThat(response.authorities()).containsExactly("platform:order:list"); } @@ -144,7 +141,7 @@ class AuthServiceSmallJwtTest { } @Test - void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { + void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() { AuthAccount account = new AuthAccount(); account.setId(52L); account.setUsername("merchant"); @@ -164,9 +161,6 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900, - snapshot.getRoles(), snapshot.getPermissions())); var response = service.refresh("refresh-old"); @@ -175,8 +169,6 @@ class AuthServiceSmallJwtTest { assertThat(response.expiresIn()).isEqualTo(1800); assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old"); assertThat(response.refreshExpiresIn()).isEqualTo(604800); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("MERCHANT_ADMIN"); assertThat(response.authorities()).containsExactly("merchant:order:list"); verify(tokenSessionStore).consume("refresh-old"); @@ -187,7 +179,6 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); - verify(overseasTokenService).issue(account, snapshot); } @Test @@ -200,7 +191,6 @@ class AuthServiceSmallJwtTest { verify(repository, never()).findById(any()); verify(jwtEncoder, never()).encode(any()); - verify(overseasTokenService, never()).issue(any(), any()); } @Test @@ -225,14 +215,9 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900, - Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"))); AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code"); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("MERCHANT_OWNER"); assertThat(response.authorities()).containsExactly("merchant:order:list"); @@ -247,6 +232,5 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); - verify(overseasTokenService).issue(account, snapshot); } } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java deleted file mode 100644 index cda05aa12..000000000 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.config.OverseasJwtProperties; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.context.TestPropertySource; - -import java.time.Duration; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.when; - -@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) -@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true") -class OverseasTokenServiceTest { - @MockBean private AuthAccountRepository accounts; - @MockBean private AuthorizationLoader authorizationLoader; - @MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder; - @Resource private OverseasTokenService service; - - @Configuration(proxyBeanMethods = false) - static class Config { - @Bean OverseasJwtProperties properties() { - return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid", - Duration.ofMinutes(15)); - } - } - - @Test - void signsShortRs256TokenWithCrossRegionClaims() { - AuthAccount account = new AuthAccount(); - account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); - account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); - String authority = "platform:overseas-master-data:quote:list"; - String merchantAuthority = "merchant:warehouse:delivery:list"; - String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list"; - String lastlegAuthority = "lastleg:pda:operate"; - String financeAuthority = "platform:finance:fee-source:edit"; - String domesticAuthority = "platform:firstleg:order:list"; - AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), - Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority, - financeAuthority, domesticAuthority), 7L, 900L); - Jwt encoded = org.mockito.Mockito.mock(Jwt.class); - when(accounts.findById(42L)).thenReturn(account); - when(authorizationLoader.load(account)).thenReturn(snapshot); - when(encoder.encode(any())).thenReturn(encoded); - when(encoded.getTokenValue()).thenReturn("overseas-token"); - - var response = service.exchange(42L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder).encode(captor.capture()); - var parameters = captor.getValue(); - assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); - assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); - assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); - assertThat(parameters.getClaims().getClaims()) - .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L) - .containsEntry("membership_id", 84L) - .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") - .containsEntry("permission_version", 7L) - .containsEntry("token_version", 0) - .containsEntry("overseas_warehouse_id", 900L) - .doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode", - "permissionVersion"); - assertThat(response.expiresIn()).isEqualTo(900); - assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); - assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority, - merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority); - } - - @Test - void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() { - AuthAccount account = new AuthAccount(); - account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L); - account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); - account.setTokenVersion(8); - AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L); - AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L); - Jwt first = org.mockito.Mockito.mock(Jwt.class); - Jwt second = org.mockito.Mockito.mock(Jwt.class); - when(encoder.encode(any())).thenReturn(first, second); - when(first.getTokenValue()).thenReturn("overseas-old-binding"); - when(second.getTokenValue()).thenReturn("overseas-new-binding"); - - var oldToken = service.issue(account, oldBinding); - var newToken = service.issue(account, newBinding); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder, times(2)).encode(captor.capture()); - var oldClaims = captor.getAllValues().get(0).getClaims(); - var newClaims = captor.getAllValues().get(1).getClaims(); - assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId()); - assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L) - .containsEntry("permission_version", 8L); - assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L) - .containsEntry("permission_version", 9L); - assertThat(newClaims.getClaims()).containsEntry("token_version", 8); - assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken()); - } - - @Test - void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() { - AuthAccount account = new AuthAccount(); - account.setId(44L); account.setAccountStatus("ACTIVE"); - AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null); - Jwt encoded = org.mockito.Mockito.mock(Jwt.class); - when(encoder.encode(any())).thenReturn(encoded); - when(encoded.getTokenValue()).thenReturn("overseas-unbound"); - - service.issue(account, unbound); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder).encode(captor.capture()); - assertThat(captor.getValue().getClaims().getClaims()) - .doesNotContainKey("overseas_warehouse_id"); - } -} diff --git a/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java b/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java new file mode 100644 index 000000000..2ac3c9895 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java @@ -0,0 +1,31 @@ +package com.baiyue.common.core.retry; + +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; + +/** Bounded exponential retry schedule; callers persist failed or expired attempts. */ +public record BoundedRetryPolicy(int maxAttempts, long baseDelaySeconds, long maxDelaySeconds, double jitter) { + public BoundedRetryPolicy { + if (maxAttempts < 1 || baseDelaySeconds < 1 || maxDelaySeconds < baseDelaySeconds + || maxDelaySeconds > 86400 || !Double.isFinite(jitter) || jitter < 0 || jitter > 0.5) { + throw new IllegalArgumentException("Invalid bounded retry policy"); + } + } + public boolean exhausted(int failedAttempts) { + return failedAttempts >= maxAttempts; + } + public Duration delayAfterFailure(int failedAttempts) { + return delayAfterFailure(failedAttempts, ThreadLocalRandom.current().nextDouble()); + } + public Duration delayAfterFailure(int failedAttempts, double random) { + if (failedAttempts < 1 || !Double.isFinite(random) || random < 0 || random > 1) { + throw new IllegalArgumentException("Invalid retry attempt or jitter sample"); + } + long seconds = baseDelaySeconds; + for (int i = 1; i < failedAttempts && seconds < maxDelaySeconds; i++) { + seconds = Math.min(maxDelaySeconds, seconds * 2); + } + long millis = Math.round(seconds * 1000 * (1 - jitter + 2 * jitter * random)); + return Duration.ofMillis(Math.min(maxDelaySeconds * 1000, Math.max(1, millis))); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java b/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java new file mode 100644 index 000000000..95f055a0a --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java @@ -0,0 +1,31 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class BoundedRetryPolicyTest { + @Test void startsAtFiveSecondsDoublesAndCapsAtThirtyMinutes() { + var policy = new BoundedRetryPolicy(12, 5, 1800, 0); + assertThat(policy.delayAfterFailure(1).toSeconds()).isEqualTo(5); + assertThat(policy.delayAfterFailure(2).toSeconds()).isEqualTo(10); + assertThat(policy.delayAfterFailure(9).toSeconds()).isEqualTo(1280); + assertThat(policy.delayAfterFailure(10).toSeconds()).isEqualTo(1800); + assertThat(policy.delayAfterFailure(Integer.MAX_VALUE).toSeconds()).isEqualTo(1800); + assertThat(policy.exhausted(11)).isFalse(); + assertThat(policy.exhausted(12)).isTrue(); + } + @Test void jitterStaysBoundedEvenAtMaximumDelay() { + var policy = new BoundedRetryPolicy(12, 5, 1800, 0.1); + assertThat(policy.delayAfterFailure(1, 0).toMillis()).isEqualTo(4500); + assertThat(policy.delayAfterFailure(1, 1).toMillis()).isEqualTo(5500); + assertThat(policy.delayAfterFailure(20, 1).toSeconds()).isEqualTo(1800); + assertThat(policy.delayAfterFailure(20, 0).toSeconds()).isEqualTo(1620); + } + @Test void invalidConfigurationFailsClosed() { + assertThatThrownBy(() -> new BoundedRetryPolicy(0, 5, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 0, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 3, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 1800, Double.NaN)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java b/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java new file mode 100644 index 000000000..803416c07 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java @@ -0,0 +1,31 @@ +package com.baiyue.common.event; + +import java.util.Objects; + +/** A consumer returns this only after its durable processing transaction commits. */ +public record DeliveryAck(String eventId, String payloadHash, Outcome outcome) { + public enum Outcome { APPLIED, DUPLICATE, STALE } + + public DeliveryAck { + if (!EventIdentifiers.isSupported(eventId)) throw new IllegalArgumentException("Invalid ACK eventId"); + if (payloadHash == null || !payloadHash.matches("sha256:[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("Invalid ACK payloadHash"); + } + Objects.requireNonNull(outcome, "ACK outcome is required"); + } + + public static DeliveryAck of(EventEnvelope event, Outcome outcome) { + return new DeliveryAck(event.eventId(), event.payloadHash(), outcome); + } + + /** HTTP and ApiResponse status must also be checked by the transport. */ + public void requireMatches(EventEnvelope sent) { + if (!eventId.equals(sent.eventId()) || !payloadHash.equalsIgnoreCase(sent.payloadHash())) { + throw new IllegalArgumentException("Consumer ACK does not match the sent event identity and payload"); + } + EventDeliveryRoute route = EventDeliveryRoute.forEventType(sent.eventType()); + if (outcome == Outcome.STALE && !route.acceptsStale()) { + throw new IllegalArgumentException("STALE is not an allowed result for " + sent.eventType()); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java new file mode 100644 index 000000000..17d68e3a6 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java @@ -0,0 +1,23 @@ +package com.baiyue.common.event; + +/** Explicit business-event destinations. There is no regional or default destination. */ +public record EventDeliveryRoute(String serviceName, String path, boolean acceptsStale) { + public static EventDeliveryRoute forEventType(String eventType) { + if (eventType == null) throw new IllegalArgumentException("Event type is required"); + return switch (eventType) { + case EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.WALLET_COMMAND_COMPLETED -> + new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", false); + case EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED -> + new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", true); + case EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED -> + new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", false); + case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> + new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", true); + case EventTypes.WALLET_COMMAND_REQUESTED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false); + case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false); + default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType); + }; + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java new file mode 100644 index 000000000..e21ce9ed2 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -0,0 +1,50 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DeliveryAckTest { + private EventEnvelope event(String type) { + return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), + RegionCode.CN, RegionCode.OVERSEAS, "0", "TEST", "1", 1, null, + null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64)); + } + + @Test + void roundTripsAndAcceptsOnlyTheSentIdentityAndHash() throws Exception { + var event = event(EventTypes.WALLET_COMMAND_REQUESTED); + var ack = DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); + var mapper = new ObjectMapper(); + assertThat(mapper.readValue(mapper.writeValueAsString(ack), DeliveryAck.class)).isEqualTo(ack); + assertThatCode(() -> ack.requireMatches(event)).doesNotThrowAnyException(); + assertThatThrownBy(() -> ack.requireMatches(event(EventTypes.WALLET_COMMAND_REQUESTED))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new DeliveryAck(event.eventId(), "sha256:" + "b".repeat(64), DeliveryAck.Outcome.APPLIED) + .requireMatches(event)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> mapper.readValue("{\"eventId\":\"" + event.eventId() + "\",\"payloadHash\":\"" + event.payloadHash() + "\",\"outcome\":\"RECEIVED\"}", DeliveryAck.class)) + .isInstanceOf(Exception.class); + } + + @Test + void staleCannotAcknowledgeWalletCommandsResultsOrIndependentFees() { + for (String type : new String[]{EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { + var event = event(type); + assertThatThrownBy(() -> DeliveryAck.of(event, DeliveryAck.Outcome.STALE).requireMatches(event)) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE).requireMatches(event)).doesNotThrowAnyException(); + } + } + + @Test + void mixedLastlegOutboxRoutesWalletToFinanceAndUnloadingToFirstleg() { + assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_REQUESTED)) + .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false)); + assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service"); + assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_COMPLETED).serviceName()).isEqualTo("lastleg-service"); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType("UnknownEvent")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType(EventTypes.IAM_USER_STATUS_CHANGED)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index f53733015..db8bb210f 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -10,19 +10,19 @@ | 功能 | 入口 | |---|---| | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | -| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作清理前的历史实现定位,不得作为跨区域共享密钥方案 | +| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | -| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | ## 修改与验证 -- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 +- 所有资源服务只接受 RS256/JWKS,统一 Issuer、Audience 和 `session-mode=redis`。私钥仅在 Auth,业务服务使用 JWKS 公钥缓存与共享 Redis 会话;不再支持 HMAC、claims 或 gateway 会话模式,不信任请求头携带的角色权限。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 -- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index f27f21ff9..25fe60b58 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 */ @AutoConfiguration( - after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class}, + after = JwksJwtDecoderAutoConfiguration.class, before = SecurityAutoConfiguration.class) @EnableMethodSecurity public class BaiyueSecurityAutoConfiguration { @@ -50,16 +50,13 @@ public class BaiyueSecurityAutoConfiguration { org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, - @Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken, @Value("${baiyue.security.session-mode:redis}") String sessionMode) { - AccessTokenSessionValidator validator; - if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); - else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken); - else { - StringRedisTemplate template=redis.getIfAvailable(); - if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); - validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper); + if (!"redis".equalsIgnoreCase(sessionMode)) { + throw new IllegalStateException("Only shared Redis access sessions are supported"); } + StringRedisTemplate template = redis.getIfAvailable(); + if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate"); + AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper); return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } diff --git a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java deleted file mode 100644 index c32b30317..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.common.security; - -import org.springframework.security.oauth2.jwt.Jwt; -import java.util.LinkedHashSet; -import java.util.Set; - -/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */ -public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator { - @Override public AccessTokenSession validate(Jwt jwt) { - Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id"))); - return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")), - values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions")))); - } - private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} } - private static Set values(Object value){ Set result=new LinkedHashSet<>(); if(value instanceof Iterable it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java deleted file mode 100644 index 062ff2fa1..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.baiyue.common.security; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.LinkedHashSet; -import java.util.Set; - -/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */ -public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator { - public static final String TOKEN_HEADER = "X-Internal-Service-Token"; - public static final String ROLES_HEADER = "X-Overseas-Roles"; - public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities"; - - private final byte[] expectedToken; - - public GatewayAccessTokenSessionValidator(String expectedToken) { - if (expectedToken == null || expectedToken.isBlank()) { - throw new IllegalStateException("session-mode=gateway requires an internal service token"); - } - this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); - } - - @Override - public AccessTokenSession validate(Jwt jwt) { - throw new IllegalArgumentException("Gateway authorization headers are required"); - } - - @Override - public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { - String supplied = request.getHeader(TOKEN_HEADER); - if (supplied == null || supplied.isBlank()) { - throw new IllegalArgumentException("Missing overseas gateway authorization context"); - } - byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expectedToken, suppliedToken)) { - throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes=" - + expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")"); - } - Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id"))); - if (userId == null) { - userId = number(jwt.getSubject()); - } - return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)), - values(request.getHeader(AUTHORITIES_HEADER))); - } - - private static Long number(Object value) { - if (value instanceof Number number) return number.longValue(); - try { return value == null ? null : Long.valueOf(value.toString()); } - catch (NumberFormatException exception) { return null; } - } - - private static Set values(String header) { - if (header == null || header.isBlank()) return Set.of(); - LinkedHashSet values = new LinkedHashSet<>(); - for (String value : header.split(",")) { - String normalized = value.trim(); - if (!normalized.isEmpty()) values.add(normalized); - } - return Set.copyOf(values); - } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java deleted file mode 100644 index b4b8261ed..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.common.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; - -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; - -/** - * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 - */ -@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true) -public class HmacJwtDecoderAutoConfiguration { - @Bean - @ConditionalOnMissingBean(JwtDecoder.class) - JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret, - @Value("${baiyue.security.issuer:auth-service}") String issuer) { - byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256")) - .macAlgorithm(MacAlgorithm.HS256).build(); - decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer)); - return decoder; - } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java index b73770483..7859c7a44 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java @@ -3,7 +3,6 @@ package com.baiyue.common.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; @@ -14,12 +13,11 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import java.util.List; -/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */ +/** 所有业务服务通过可轮换 JWKS 验证 RS256,会话由共享 Redis 校验。 */ @AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks") public class JwksJwtDecoderAutoConfiguration { @Bean @ConditionalOnMissingBean(JwtDecoder.class) - JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri, + JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri, @Value("${baiyue.security.issuer:auth-service}") String issuer, @Value("${baiyue.security.audience:lastleg-api}") String audience) { NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri) diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java index 3ea44844c..ea96d2ab9 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -28,10 +28,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AuthenticationEntryPoint authenticationEntryPoint; private final AccessTokenSessionValidator sessionValidator; - public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, - AuthenticationEntryPoint authenticationEntryPoint) { - this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null); - } public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint authenticationEntryPoint, diff --git a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d4d261650..6f4cadda1 100644 --- a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,2 @@ -com.baiyue.common.security.HmacJwtDecoderAutoConfiguration com.baiyue.common.security.JwksJwtDecoderAutoConfiguration com.baiyue.common.security.BaiyueSecurityAutoConfiguration diff --git a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java deleted file mode 100644 index d9c86a28f..000000000 --- a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.baiyue.common.security; - -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class GatewayAccessTokenSessionValidatorTest { - private final GatewayAccessTokenSessionValidator validator = - new GatewayAccessTokenSessionValidator("gateway-secret"); - - @Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); - request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); - request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, - "platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view"); - - AccessTokenSession session = validator.validate(jwt(), request); - - assertThat(session.userId()).isEqualTo(42L); - assertThat(session.roles()).containsExactly("PLATFORM_ADMIN"); - assertThat(session.permissions()).containsExactlyInAnyOrder( - "platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view"); - } - - @Test void rejectsForgedOrMissingGatewayToken() { - MockHttpServletRequest forged = new MockHttpServletRequest(); - forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged"); - forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, - "platform:overseas-warehouse:last-mile-order:list"); - assertThatThrownBy(() -> validator.validate(jwt(), forged)) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway"); - - assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest())) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway"); - } - - private Jwt jwt() { - return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", 0L).claim("app_code", "PLATFORM") - .claim("permission_version", 7L).claim("token_version", 1).build(); - } -} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 9ea0f4fa1..307a44d56 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -29,7 +29,8 @@ class JwtAuthenticationFilterTest { .claim("user_id", 1L).build(); JwtDecoder decoder = token -> jwt; JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(), - new JsonAuthenticationEntryPoint(new ObjectMapper())); + new JsonAuthenticationEntryPoint(new ObjectMapper()), + token -> new AccessTokenSession(1, 1L, java.util.Set.of(), java.util.Set.of())); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -43,20 +44,25 @@ class JwtAuthenticationFilterTest { } @Test - void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception { + void authenticatesUnifiedTokenWithServerSessionIgnoringGatewayHeaders() throws Exception { Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) - .claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("jti", "session-42").claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM") .claim("permission_version", 7L).claim("token_version", 1).build(); + var redis = org.mockito.Mockito.mock(org.springframework.data.redis.core.StringRedisTemplate.class); + var values = org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class); + org.mockito.Mockito.when(redis.opsForValue()).thenReturn(values); + org.mockito.Mockito.when(values.get("baiyue:auth:access:session-42")) + .thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}"); JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - new GatewayAccessTokenSessionValidator("gateway-secret")); + new RedisAccessTokenSessionValidator(redis, "baiyue:auth")); MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/api/platform/v1/last-leg/orders"); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); - request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); - request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); - request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view"); + request.addHeader("X-Internal-Service-Token", "gateway-secret"); + request.addHeader("X-Overseas-Roles", "FORGED_ADMIN"); + request.addHeader("X-Overseas-Authorities", "platform:wallet:write"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicBoolean invoked = new AtomicBoolean(); @@ -64,7 +70,8 @@ class JwtAuthenticationFilterTest { var authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNotNull(); assertThat(authentication.getAuthorities()).extracting("authority") - .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view"); + .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view") + .doesNotContain("ROLE_FORGED_ADMIN", "platform:wallet:write"); invoked.set(true); }); diff --git a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java new file mode 100644 index 000000000..4280bbe8e --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java @@ -0,0 +1,22 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UnifiedSessionModeTest { + @Test + void rejectsLegacySessionModesAndMissingRedisInsteadOfTrustingClaimsOrHeaders() { + var configuration = new BaiyueSecurityAutoConfiguration(); + var beans = new StaticListableBeanFactory(); + var redis = beans.getBeanProvider(StringRedisTemplate.class); + for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) { + assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null, + new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), + redis, new ObjectMapper(), "baiyue:auth", mode)) + .isInstanceOf(IllegalStateException.class); + } + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java new file mode 100644 index 000000000..25768312a --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java @@ -0,0 +1,33 @@ +package com.baiyue.common.web; + +import feign.Request; +import feign.Capability; +import feign.Retryer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import java.util.concurrent.TimeUnit; + +/** Explicitly selected by reliable event clients; never registered as a global configuration. */ +public class EventDeliveryFeignConfiguration { + @Bean + public DeliveryTransportCapability eventDeliveryCapability( + @Value("${baiyue.event-delivery.connect-timeout-ms:3000}") int connectTimeout, + @Value("${baiyue.event-delivery.read-timeout-ms:15000}") int readTimeout, + @Value("${baiyue.event-delivery.lease-seconds:60}") long leaseSeconds) { + if (connectTimeout < 1 || readTimeout < 1 || leaseSeconds < 1 + || leaseSeconds > Integer.MAX_VALUE || (long) connectTimeout + readTimeout >= leaseSeconds * 1000) { + throw new IllegalArgumentException("Event delivery timeouts must fit inside the lease"); + } + return new DeliveryTransportCapability(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, false)); + } + /** Applied at Feign build time after global/client properties, without changing other clients. */ + public static final class DeliveryTransportCapability implements Capability { + private final Request.Options options; + public DeliveryTransportCapability(Request.Options options) { this.options = options; } + @Override public Request.Options enrich(Request.Options ignored) { return options; } + @Override public Retryer enrich(Retryer ignored) { + // The persisted Outbox owns the retry budget. + return Retryer.NEVER_RETRY; + } + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java new file mode 100644 index 000000000..dd94c9ff5 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java @@ -0,0 +1,49 @@ +package com.baiyue.common.web; + +import feign.*; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryFeignConfigurationTest { + interface Endpoint { @RequestLine("POST /events") String send(); } + + @Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() { + var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60); + var observed = new AtomicReference(); + Endpoint endpoint = Feign.builder().options(new Request.Options(1, 5000)) + .addCapability(capability).client((request, options) -> { + observed.set(options); + return Response.builder().request(request).status(200).reason("OK") + .headers(Map.of()).body("ok", StandardCharsets.UTF_8).build(); + }).target(Endpoint.class, "http://event-consumer.invalid"); + assertThat(endpoint.send()).isEqualTo("ok"); + assertThat(observed.get().connectTimeoutMillis()).isEqualTo(3000); + assertThat(observed.get().readTimeoutMillis()).isEqualTo(15000); + assertThat(observed.get().isFollowRedirects()).isFalse(); + } + + @Test void retryAfterResponseCannotTriggerHiddenHttpRetries() { + var calls = new AtomicInteger(); + Endpoint endpoint = Feign.builder().retryer(new Retryer.Default(1, 1, 3)) + .addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> { + calls.incrementAndGet(); + return Response.builder().request(request).status(503).reason("Unavailable") + .headers(Map.of("Retry-After", List.of("0"))).build(); + }).target(Endpoint.class, "http://event-consumer.invalid"); + assertThatThrownBy(endpoint::send).isInstanceOf(RetryableException.class); + assertThat(calls.get()).isEqualTo(1); + } + + @Test void invalidTimeoutsAndTooShortLeaseAreRejected() { + var configuration = new EventDeliveryFeignConfiguration(); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(0, 15000, 60)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(3000, 15000, 18)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(3000, -1, 60)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml index 77ec1e8b2..7b8f3bd01 100644 --- a/config/nacos/local/lastleg-service.yml +++ b/config/nacos/local/lastleg-service.yml @@ -23,7 +23,7 @@ baiyue: security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml index 7edeaec76..a3b8cf9ee 100644 --- a/config/nacos/local/warehouse-overseas-service.yml +++ b/config/nacos/local/warehouse-overseas-service.yml @@ -13,7 +13,7 @@ baiyue: token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} security: jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/finance/pom.xml b/finance/pom.xml index 8da2c4701..8fce50d06 100644 --- a/finance/pom.xml +++ b/finance/pom.xml @@ -25,6 +25,7 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.bootspring-boot-starter-actuator org.springframework.bootspring-boot-starter-testtest + com.h2databaseh2test diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java deleted file mode 100644 index 1aab9c099..000000000 --- a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.baiyue.finance.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient", - url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}") -public interface WalletResultEventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java new file mode 100644 index 000000000..793e75493 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java @@ -0,0 +1,19 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "financeWalletResultLastlegClient", + url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface WalletResultLastlegClient { + @PostMapping("/internal/lastleg/events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java index 88f9dc71e..cc21914d0 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java @@ -25,7 +25,7 @@ public class InternalBusinessFeeSourceController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping - public ApiResponse> consume( + public ApiResponse consume( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { if (token == null || expectedToken == null || expectedToken.isBlank() @@ -33,6 +33,6 @@ public class InternalBusinessFeeSourceController { expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); } - return FinanceResponses.success(Map.of("feeSourceId", service.consume(event))); + return FinanceResponses.success(service.consume(event)); } } diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java index 51a94c358..ea45923b9 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java @@ -24,7 +24,7 @@ public class InternalWalletCommandEventController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping - public ApiResponse> consume( + public ApiResponse consume( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { if (token == null || expectedToken == null || expectedToken.isBlank() @@ -32,6 +32,6 @@ public class InternalWalletCommandEventController { expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); } - return FinanceResponses.success(Map.of("eventId", service.consume(event))); + return FinanceResponses.success(service.consume(event)); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 2089ae768..ee71a1255 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -2,6 +2,7 @@ package com.baiyue.finance.service; import com.baiyue.common.event.BusinessFeeSourceChangedV1; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -28,7 +29,7 @@ public class BusinessFeeSourceEventService { private final FinanceSupplierValidator supplierValidator; @Transactional - public long consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { try { envelope.requireValid(); } catch (RuntimeException exception) { @@ -51,7 +52,7 @@ public class BusinessFeeSourceEventService { if (!envelope.payloadHash().equals(row.get("payload_hash"))) throw error("FIN-EVT-002", "同一事件ID对应不同费用快照"); if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) - return id.longValue(); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); } jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", @@ -59,7 +60,7 @@ public class BusinessFeeSourceEventService { long result = apply(sourceSystem, source); jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?", result, sourceSystem, envelope.eventId()); - return result; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private long apply(String system, BusinessFeeSourceChangedV1 source) { diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index 843b3d644..a386fe13e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -1,6 +1,7 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; @@ -14,10 +15,11 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; @@ -27,35 +29,62 @@ import java.util.Locale; import java.util.Set; @Service -@RequiredArgsConstructor public class WalletCommandEventService { private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private static final Set FINAL_REJECTIONS = Set.of( + "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400"); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceWalletService walletService; private final TransactionTemplate transactions; - public String consume(EventEnvelope envelope) { + public WalletCommandEventService(JdbcTemplate jdbc, ObjectMapper objectMapper, + FinanceWalletService walletService, PlatformTransactionManager transactionManager) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.walletService = walletService; + this.transactions = new TransactionTemplate(transactionManager); + this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public DeliveryAck consume(EventEnvelope envelope) { WalletCommandRequestedV1 command = validate(envelope); - String existing = existingHash(envelope.eventId()); - if (existing != null && !existing.equals(envelope.payloadHash())) { + // Keep event identity durable even if processing rolls back. + transactions.executeWithoutResult(status -> reserve(envelope)); + try { + return transactions.execute(status -> { + requireLockedIdentity(envelope); + if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + // Wallet methods join this transaction: money, the Inbox outcome and + // the result Outbox commit together, or all roll back. + WalletHold hold = execute(command); + if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED", + hold.status(), hold.amount(), null, null)); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); + }); + } catch (FinanceEventException exception) { + if (!FINAL_REJECTIONS.contains(exception.code())) throw exception; + // A joined wallet transaction may have been marked rollback-only. Persist + // a final business rejection only AFTER that transaction has rolled back. + return transactions.execute(status -> { + requireLockedIdentity(envelope); + if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED", + null, command.amount(), exception.code(), exception.getMessage())); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); + }); + } + } + + private void requireLockedIdentity(EventEnvelope envelope) { + List hashes = jdbc.queryForList( + "SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=? FOR UPDATE", + String.class, envelope.eventId()); + if (hashes.size() != 1) throw new IllegalStateException("资金指令身份记录不存在"); + if (!hashes.get(0).equals(envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令"); } - if (existing == null) reserve(envelope); - if (resultExists(envelope.eventId())) return envelope.eventId(); - - WalletCommandCompletedV1 result; - try { - WalletHold hold = transactions.execute(status -> execute(command)); - result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null); - } catch (FinanceEventException exception) { - result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage()); - } catch (RuntimeException exception) { - result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage())); - } - WalletCommandCompletedV1 finalResult = result; - transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult)); - return envelope.eventId(); } private WalletHold execute(WalletCommandRequestedV1 command) { @@ -104,7 +133,10 @@ public class WalletCommandEventService { try { jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))", envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope)); - } catch (DuplicateKeyException ignored) { } + } catch (DuplicateKeyException duplicate) { + // The durable winner is verified again under SELECT FOR UPDATE before + // any wallet call, including when both requests initially saw no record. + } } private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) { @@ -114,16 +146,12 @@ public class WalletCommandEventService { WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); - jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed)); jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?", result.result(), json(result), commandEnvelope.eventId()); } - private String existingHash(String eventId) { - List rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId); - return rows.isEmpty() ? null : rows.get(0); - } private boolean resultExists(String eventId) { Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class, "WALLET_RESULT:" + eventId); @@ -139,9 +167,4 @@ public class WalletCommandEventService { catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); } } private static boolean blank(String value) { return value == null || value.isBlank(); } - private static String safe(String value) { - if (value == null || value.isBlank()) return "资金指令执行失败"; - String normalized = value.replaceAll("[\\r\\n\\t]+", " "); - return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); - } } diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java index 2a57aaed1..2c787ddf8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -1,7 +1,8 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.finance.client.WalletResultEventBridgeClient; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.finance.client.WalletResultLastlegClient; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,10 +22,13 @@ import java.util.UUID; public class WalletResultOutboxRelay { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; - private final WalletResultEventBridgeClient client; + private final WalletResultLastlegClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts; - private final String worker = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}") public void relay() { @@ -33,24 +37,33 @@ public class WalletResultOutboxRelay { } public void deliver(String eventId) { + var policy = retryPolicy(); + String worker = UUID.randomUUID().toString(); LocalDateTime now = LocalDateTime.now(); int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", worker, now, now, eventId, now); if (claimed != 1) return; Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + int failures = ((Number) row.get("retry_count")).intValue(); + if (policy.exhausted(failures)) { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + "自动投递次数已耗尽(含租约超时)", eventId, worker); + return; + } try { EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); var response = client.receive(internalToken, envelope); - if (response == null || !response.successful() || response.data() == null - || !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件"); - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", + if (response == null || !response.successful() || response.data() == null) + throw new IllegalStateException("Lastleg 未确认资金结果事件"); + response.data().requireMatches(envelope); + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker); } catch (Exception exception) { - int retries = ((Number) row.get("retry_count")).intValue() + 1; - String status = retries >= maxAttempts ? "DEAD" : "PENDING"; - long delay = Math.min(1800, 5L << Math.min(retries, 8)); - jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", - status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker); + int retries = failures + 1; + String status = policy.exhausted(retries) ? "DEAD" : "PENDING"; + LocalDateTime next = policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries)); + jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + status, retries, next, safe(exception.getMessage()), eventId, worker); log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage()); } } @@ -59,7 +72,21 @@ public class WalletResultOutboxRelay { return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); } private void recoverExpired() { - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at values = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") @@ -97,7 +97,7 @@ class BusinessFeeSourceEventServiceTest { new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); ArgumentCaptor source = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture()); assertThat(source.getValue()[0]).isEqualTo("LASTLEG"); @@ -138,7 +138,7 @@ class BusinessFeeSourceEventServiceTest { new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) .thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L))); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); verify(jdbc, never()).update(anyString(), any(Object[].class)); } @@ -189,7 +189,7 @@ class BusinessFeeSourceEventServiceTest { void voidedSourceCannotBeRevivedByPublicationReplay() { var event = payableEvent("PUBLISHED", 1); existingVersion(event.payload().get("sourceHash").asText(), "VOIDED"); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); noFinancialWrites(); } @@ -197,7 +197,7 @@ class BusinessFeeSourceEventServiceTest { void voidTargetsOnlyMatchingOverseasCostVersion() { var event = payableEvent("VOIDED", 1); existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE"); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1)); verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java index 42481247f..05129261d 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -16,14 +16,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.TransactionCallback; -import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; import java.math.BigDecimal; import java.time.Instant; import java.util.List; import java.util.UUID; -import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -34,39 +32,31 @@ class WalletCommandEventServiceTest { private final JdbcTemplate jdbc = mock(JdbcTemplate.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final FinanceWalletService wallet = mock(FinanceWalletService.class); - private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private final PlatformTransactionManager transactions = mock(PlatformTransactionManager.class); private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions); @BeforeEach void executeTransactionCallbacks() { - when(transactions.execute(any())).thenAnswer(invocation -> { - TransactionCallback callback = invocation.getArgument(0); - return callback.doInTransaction(mock(TransactionStatus.class)); - }); - doAnswer(invocation -> { - Consumer callback = invocation.getArgument(0); - callback.accept(mock(TransactionStatus.class)); - return null; - }).when(transactions).executeWithoutResult(any()); + when(transactions.getTransaction(any())).thenAnswer(invocation -> mock(TransactionStatus.class)); } @Test void freezesWalletAndPublishesCompletedEvent() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50")); when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) - .thenReturn(List.of()); + .thenReturn(List.of(envelope.payloadHash())); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn( new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null, null, null, null)); - assertThat(service.consume(envelope)).isEqualTo(envelope.eventId()); + assertThat(service.consume(envelope).eventId()).isEqualTo(envelope.eventId()); verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request -> request.amount().compareTo(new BigDecimal("88.50")) == 0 && request.businessNo().equals("PO-1"))); - verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class)); + verify(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId())); } @@ -75,7 +65,7 @@ class WalletCommandEventServiceTest { void settledWalletHoldMarksMatchingReceivableSourceSettled() { EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50")); when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) - .thenReturn(List.of()); + .thenReturn(List.of(envelope.payloadHash())); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"), eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L, @@ -87,6 +77,35 @@ class WalletCommandEventServiceTest { verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L)); } + @Test + void transientWalletFailureMustNotBecomeAFinalResult() { + EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString())) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + var failure = new org.springframework.dao.TransientDataAccessResourceException("temporary database failure"); + when(wallet.freeze(anyLong(), anyString(), any())).thenThrow(failure); + + assertThatThrownBy(() -> service.consume(event)).isSameAs(failure); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); + } + + @Test + void losingInboxInsertMustRecheckThePersistedHash() { + EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString())) + .thenAnswer(invocation -> ((String) invocation.getArgument(0)).contains("FOR UPDATE") + ? List.of("a-different-persisted-hash") : List.of()); + when(jdbc.update(startsWith("INSERT INTO fin_wallet_command_inbox"), any(Object[].class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("another request won")); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002"); + verifyNoInteractions(wallet); + } + @Test void rejectsCustomerMismatchBeforeTouchingWallet() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java new file mode 100644 index 000000000..bd88b0dbd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -0,0 +1,205 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.*; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceWalletService; +import com.baiyue.finance.service.WalletCommandEventService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.TransientDataAccessResourceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real JDBC transactions and row locks; the wallet boundary is a durable idempotent test double. */ +class WalletCommandTransactionTest { + private JdbcTemplate jdbc; + private FinanceWalletService wallet; + private WalletCommandEventService service; + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setup() { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = spy(new JdbcTemplate(source)); + jdbc.execute("CREATE TABLE fin_wallet_command_inbox(event_id VARCHAR(128) PRIMARY KEY,event_type VARCHAR(100),payload_hash VARCHAR(128),payload_json JSON,status VARCHAR(32),received_at TIMESTAMP,result_json JSON,processed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(128) PRIMARY KEY,event_key VARCHAR(200) UNIQUE,payload_json JSON,status VARCHAR(32),next_retry_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE wallet_effect(idempotency_key VARCHAR(128) PRIMARY KEY,amount DECIMAL(18,2))"); + wallet = mock(FinanceWalletService.class); + service = new WalletCommandEventService(jdbc, mapper, wallet, new DataSourceTransactionManager(source)); + doAnswer(invocation -> { + String key = invocation.getArgument(1); + // Models the existing FinanceWalletService's durable replay behavior. + if (jdbc.queryForObject("SELECT COUNT(*) FROM wallet_effect WHERE idempotency_key=?", Integer.class, key) == 0) { + jdbc.update("INSERT INTO wallet_effect VALUES(?,?)", key, new BigDecimal("10.00")); + } + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + } + + @Test + void transientFailureRollsBackMoneyAndCanRetryTheOriginalEvent() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + AtomicBoolean first = new AtomicBoolean(true); + doAnswer(invocation -> { + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + if (first.getAndSet(false)) throw new TransientDataAccessResourceException("temporary outage"); + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isZero(); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED"); + } + + @Test + void businessRejectionRollsBackMoneyAndPublishesExactlyOneFailedResult() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doAnswer(invocation -> { + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + throw new FinanceEventException("FIN-WALLET-INSUFFICIENT", "insufficient balance"); + }).when(wallet).freeze(anyLong(), anyString(), any()); + service.consume(event); + service.consume(event); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("FAILED"); + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + } + + @Test + void retryableFinanceCodeDoesNotPublishATerminalResult() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doThrow(new FinanceEventException("FIN-IDEMPOTENCY-PROCESSING", "in progress")) + .when(wallet).freeze(anyLong(), anyString(), any()); + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(FinanceEventException.class); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(count("fin_wallet_event_outbox")).isZero(); + } + + @Test + void failureAfterResultInsertRollsBackMoneyAndResultAndCanRetry() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + AtomicBoolean first = new AtomicBoolean(true); + doAnswer(invocation -> { + if (first.getAndSet(false)) throw new TransientDataAccessResourceException("result commit failed"); + return invocation.callRealMethod(); + }).when(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), any(Object[].class)); + + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isZero(); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED"); + verify(wallet, times(2)).freeze(eq(31L), eq("freeze-1"), any()); + } + + @Test + void differentPayloadCannotReplaceIdentityAfterAResultFailure() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doThrow(new TransientDataAccessResourceException("outbox unavailable")) + .when(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThatThrownBy(() -> service.consume(event(event.eventId(), "20.00"))) + .isInstanceOf(FinanceEventException.class) + .extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-EVT-002"); + assertThat(count("wallet_effect")).isZero(); + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + } + + @Test + void concurrentSamePayloadExecutesOnce() throws Exception { + runConcurrent(false); + } + + @Test + void concurrentDifferentPayloadIsRejectedBeforeWalletExecution() throws Exception { + runConcurrent(true); + } + + private void runConcurrent(boolean differentPayload) throws Exception { + EventEnvelope firstEvent = event(UUID.randomUUID().toString(), "10.00"); + EventEnvelope secondEvent = differentPayload ? event(firstEvent.eventId(), "20.00") : firstEvent; + CountDownLatch enteredWallet = new CountDownLatch(1); + CountDownLatch releaseWallet = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + doAnswer(invocation -> { + enteredWallet.countDown(); + if (!releaseWallet.await(5, TimeUnit.SECONDS)) throw new AssertionError("test timed out"); + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> service.consume(firstEvent)); + assertThat(enteredWallet.await(5, TimeUnit.SECONDS)).isTrue(); + Future second = executor.submit(() -> { + secondStarted.countDown(); + return service.consume(secondEvent); + }); + assertThat(secondStarted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + releaseWallet.countDown(); + assertThat(first.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId()); + if (differentPayload) { + assertThatThrownBy(() -> second.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class).hasCauseInstanceOf(FinanceEventException.class) + .satisfies(e -> assertThat(((FinanceEventException) e.getCause()).code()).isEqualTo("FIN-EVT-002")); + } else { + assertThat(second.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId()); + } + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(firstEvent)).isEqualTo("SUCCEEDED"); + } finally { + releaseWallet.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private int count(String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + } + + private String inboxStatus(EventEnvelope event) { + return jdbc.queryForObject("SELECT status FROM fin_wallet_command_inbox WHERE event_id=?", String.class, event.eventId()); + } + + private EventEnvelope event(String id, String amount) { + var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", + new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); + var payload = mapper.valueToTree(command); + return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, + Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "31", "WALLET_HOLD", "PO-1", 1, + null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + + private WalletHold hold() { + return new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("10.00"), + new BigDecimal("10.00"), BigDecimal.ZERO, "FROZEN", 71L, null, null, null, null); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java new file mode 100644 index 000000000..acb6c036e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java @@ -0,0 +1,75 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WalletResultLastlegClient; +import com.baiyue.finance.service.WalletResultOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletResultOutboxLeaseTest { + @Test void expiredAttemptCannotOverwriteNewAttemptInSameRelayInstance() throws Exception { + var jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))"); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); + var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1, + Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))", + event.eventId(), mapper.writeValueAsString(event)); + var client = mock(WalletResultLastlegClient.class); + var relay = new WalletResultOutboxRelay(jdbc, mapper, client); + ReflectionTestUtils.setField(relay, "internalToken", "token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + var firstEntered = new CountDownLatch(1); + var secondEntered = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var releaseSecond = new CountDownLatch(1); + var calls = new AtomicInteger(); + when(client.receive(eq("token"), any())).thenAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + firstEntered.countDown(); + if (!releaseFirst.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + throw new IllegalStateException("late failure from expired request"); + } + secondEntered.countDown(); + if (!releaseSecond.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + return ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + }); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future first = pool.submit(() -> relay.deliver(event.eventId())); + assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue(); + String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); + // Simulate lease recovery while the original HTTP request has not returned. + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL"); + Future second = pool.submit(() -> relay.deliver(event.eventId())); + assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue(); + String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); + assertThat(newOwner).isNotEqualTo(oldOwner); + releaseFirst.countDown(); + first.get(10, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING"); + assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + releaseSecond.countDown(); + second.get(10, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED"); + } finally { + releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow(); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java new file mode 100644 index 000000000..dfa0b1046 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java @@ -0,0 +1,86 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WalletResultLastlegClient; +import com.baiyue.finance.service.WalletResultOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletResultRetryTest { + JdbcTemplate jdbc; + WalletResultLastlegClient client; + WalletResultOutboxRelay relay; + String eventId; + String originalJson; + + @BeforeEach void setup() throws Exception { + jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))"); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); + eventId = UUID.randomUUID().toString(); + var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(), + RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + originalJson = mapper.writeValueAsString(event); + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)", + eventId, originalJson, LocalDateTime.now().minusSeconds(1)); + client = mock(WalletResultLastlegClient.class); + relay = new WalletResultOutboxRelay(jdbc, mapper, client); + ReflectionTestUtils.setField(relay, "internalToken", "token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 3); + ReflectionTestUtils.setField(relay, "retryJitter", 0.0); + } + + @Test void failedDeliveryBacksOffThenStopsAtConfiguredLimitWithoutChangingPayload() { + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("service unavailable")); + LocalDateTime before = LocalDateTime.now(); + relay.deliver(eventId); + LocalDateTime next = jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class); + assertThat(next).isAfterOrEqualTo(before.plusSeconds(5)).isBefore(LocalDateTime.now().plusSeconds(6)); + assertThat(status()).isEqualTo("PENDING"); + for (int i = 0; i < 2; i++) { + jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1)); + relay.deliver(eventId); + } + assertThat(status()).isEqualTo("DEAD"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); + assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT payload_json FROM fin_wallet_event_outbox", String.class)).isEqualTo(originalJson); + relay.deliver(eventId); + verify(client, times(3)).receive(anyString(), any()); + } + + @Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?", + LocalDateTime.now().minusSeconds(61)); + relay.relay(); + assertThat(status()).isEqualTo("DEAD"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); + verifyNoInteractions(client); + } + + @Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?", + LocalDateTime.now().minusSeconds(61)); + relay.relay(); + assertThat(status()).isEqualTo("PENDING"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now()); + verifyNoInteractions(client); + } + + private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 691ae867a..2239efda4 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -41,7 +41,7 @@ | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/EventBridgeClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | @@ -54,7 +54,7 @@ | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | -| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` | | 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | | SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java deleted file mode 100644 index f48074f63..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.firstleg.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -/** 将本地业务 Outbox 投递到跨区事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") -public interface EventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive( - @RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java new file mode 100644 index 000000000..b6ea27a97 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 将本地业务 Outbox 投递到Lastleg 消费者。 */ +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "loadingCompletedLastlegEventClient", url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface LastlegEventClient { + @PostMapping("/internal/lastleg/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java index 039cce166..34fb7eae5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventTypes; import com.baiyue.firstleg.service.OverseasContainerEventConsumer; import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer; @@ -27,14 +28,17 @@ public class InternalOverseasContainerEventController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping("/internal/firstleg/v1/container-events") - public ApiResponse receive( + public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { authenticate(token); - Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - ? detainedConsumer.consume(event) - : EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) - ? deliveryCompletedConsumer.consume(event) : consumer.consume(event); + event.requireValid(); + DeliveryAck result = switch (event.eventType()) { + case EventTypes.OVERSEAS_CONTAINER_UNLOADED -> consumer.consume(event); + case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> detainedConsumer.consume(event); + case EventTypes.OVERSEAS_DELIVERY_COMPLETED -> deliveryCompletedConsumer.consume(event); + default -> throw new IllegalArgumentException("不支持的 Firstleg 业务事件类型"); + }; return ApiResponse.success(result); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index 403f5a5c1..e5ac78d78 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -38,6 +38,7 @@ public class LoadingCompletedOutboxRepository { .or().le(InboundOutboxEntity::getNextRetryAt, now)) .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) + .setSql("retry_count = retry_count + CASE WHEN publish_status='PROCESSING' THEN 1 ELSE 0 END") .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) .set(InboundOutboxEntity::getLockedAt, now)) == 1; } @@ -48,6 +49,14 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) .set(InboundOutboxEntity::getLastError, null)); } + public void dead(long id, String worker, int failures, String error) { + mapper.update(null, claimedBase(id, worker) + .set(InboundOutboxEntity::getPublishStatus, "DEAD") + .set(InboundOutboxEntity::getRetryCount, failures) + .set(InboundOutboxEntity::getNextRetryAt, null) + .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) + .set(InboundOutboxEntity::getLastError, truncate(error))); + } public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING") .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index f01597951..d7c99bc08 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -2,8 +2,9 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.core.retry.BoundedRetryPolicy; import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.client.LastlegEventClient; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,25 +17,39 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */ +/** 至少一次投递国内头程业务事件;Lastleg按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j public class LoadingCompletedOutboxRelay { @Resource private LoadingCompletedOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private LastlegEventClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; - @Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds; - private final String workerId = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; @Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); LocalDateTime now=LocalDateTime.now(); - LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10)); + LocalDateTime expiredLeaseBefore=now.minusSeconds(leaseSeconds); for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { + String workerId = UUID.randomUUID().toString(); + now = LocalDateTime.now(); + expiredLeaseBefore = now.minusSeconds(leaseSeconds); + if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); if(row==null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + continue; + } try { EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); EventEnvelopeValidator.requireValid(event); @@ -43,25 +58,24 @@ public class LoadingCompletedOutboxRelay { repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); - repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); - log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}", + if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); + else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), error); + log.warn("头程业务事件投递失败: eventType={}, eventKey={}, reason={}", row.getEventType(), row.getEventKey(), error); } } } private void requireAcknowledgement(EventEnvelope event, - ApiResponse response) { - if(response==null) throw new IllegalStateException("事件桥未返回响应"); + ApiResponse response) { + if(response==null) throw new IllegalStateException("Lastleg未返回响应"); if(!response.successful()) { - throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code() + throw new IllegalStateException("Lastleg拒绝头程业务事件: code=" + response.code() + ", message=" + response.message() + ", requestId=" + response.requestId() + ", traceId=" + response.traceId()); } - if(response.data()==null || !event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId() - + ", actualEventId=" + (response.data()==null?null:response.data().eventId())); - } + if (response.data() == null) throw new IllegalStateException("Lastleg ACK 缺失"); + response.data().requireMatches(event); } private String safeError(Exception exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 5bdd15cc2..1eb91f8c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -26,8 +27,8 @@ public class OverseasContainerEventConsumer { private final ContainerEventInboxMapper inbox; private final ObjectMapper objectMapper; - @Transactional - public ContainerBookingEntity consume(EventEnvelope event) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope event) { validateEnvelope(event); OverseasContainerUnloadedV1 payload = payload(event); if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank() @@ -50,7 +51,8 @@ public class OverseasContainerEventConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerEventInboxEntity row = inbox(event); try { @@ -58,18 +60,20 @@ public class OverseasContainerEventConsumer { } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt()); - containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt, + int updated = containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt, unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + if (updated != 1) throw new IllegalStateException("卸柜事实未写入:来源版本冲突或货柜状态已变化"); row.setStatus("PROCESSED"); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(row); - return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); } private void validateEnvelope(EventEnvelope event) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java index 6fa43e279..2834891dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -37,8 +38,8 @@ public class OverseasDeliveryCompletedConsumer { private final ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Transactional - public InboundOrderEntity consume(EventEnvelope event) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope event) { validate(event); OverseasDeliveryCompletedV1 payload = payload(event); long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效"); @@ -51,14 +52,16 @@ public class OverseasDeliveryCompletedConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return required(inboundId, tenantId, payload); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerEventInboxEntity inboxRow = inbox(event); try { inbox.insert(inboxRow); } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return required(inboundId, tenantId, payload); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } InboundOrderEntity inbound = required(inboundId, tenantId, payload); if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) { @@ -91,7 +94,7 @@ public class OverseasDeliveryCompletedConsumer { } inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(inboxRow); - return required(inboundId, tenantId, payload); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); } private void validate(EventEnvelope event) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java index df37c939f..8c9df62d2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -33,8 +34,8 @@ public class OverseasInboundOrderDetainedConsumer { private final ContainerEventInboxMapper inbox; private final ObjectMapper objectMapper; - @Transactional - public InboundOrderEntity consume(EventEnvelope event) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope event) { validateEnvelope(event); OverseasInboundOrderDetainedChangedV1 payload = payload(event); long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效"); @@ -52,7 +53,8 @@ public class OverseasInboundOrderDetainedConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return requiredInbound(inboundId, tenantId, payload.inboundCode()); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerEventInboxEntity inboxRow = inbox(event); try { @@ -60,21 +62,24 @@ public class OverseasInboundOrderDetainedConsumer { } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return requiredInbound(inboundId, tenantId, payload.inboundCode()); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } InboundOrderEntity inbound = requiredInbound(inboundId, tenantId, payload.inboundCode()); + boolean applied = false; if (inbound.getLastOverseasHoldVersion() == null || event.aggregateVersion() > inbound.getLastOverseasHoldVersion()) { int updated = inboundOrders.applyOverseasHold(tenantId, inboundId, payload.detained(), payload.detained() ? payload.reason() : null, event.aggregateVersion(), parseOperatorId(payload.operatorId())); - if (updated == 1) insertHistory(event, payload, tenantId, inboundId); + applied = updated == 1; + if (applied) insertHistory(event, payload, tenantId, inboundId); } inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(inboxRow); - return requiredInbound(inboundId, tenantId, payload.inboundCode()); + return DeliveryAck.of(event, applied ? DeliveryAck.Outcome.APPLIED : DeliveryAck.Outcome.STALE); } private void validateEnvelope(EventEnvelope event) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index 3970763f6..288886bb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -8,7 +8,7 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.RegionCode; -import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.client.LastlegEventClient; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -31,7 +31,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LoadingCompletedOutboxRelayTest { @Mock private LoadingCompletedOutboxRepository repository; - @Mock private EventBridgeClient client; + @Mock private LastlegEventClient client; private ObjectMapper objectMapper; private LoadingCompletedOutboxRelay relay; @@ -44,18 +44,18 @@ class LoadingCompletedOutboxRelayTest { ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); - ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + ReflectionTestUtils.setField(relay, "baseDelaySeconds", 5L); } @Test - void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception { + void claimsExpiredLeasesAndPublishesAfterConsumerAcknowledgement() throws Exception { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( - new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00"))); + com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED))); relay.relay(); @@ -110,11 +110,53 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( - "IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, " + "IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, " + "requestId=request-123, traceId=trace-456")); verify(repository, never()).published(anyLong(), anyString(), any()); } + @Test + void matchingEventIdWithWrongPayloadHashDoesNotPublish() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), + com.baiyue.common.event.DeliveryAck.Outcome.APPLIED))); + relay.relay(); + verify(repository, never()).published(anyLong(), anyString(), any()); + verify(repository).retry(eq(9001L), anyString(), any(), contains("ACK does not match")); + } + + @Test + void twelfthFailureGoesDeadInsteadOfSchedulingAnotherRetry() throws Exception { + InboundOutboxEntity row = row(event()); + row.setRetryCount(11); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); + relay.relay(); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("consumer unavailable")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void recoveredExhaustedLeaseDoesNotSendAgain() throws Exception { + InboundOutboxEntity row = row(event()); + row.setRetryCount(12); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + relay.relay(); + verifyNoInteractions(client); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("租约超时")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + private InboundOutboxEntity row(EventEnvelope event) throws Exception { InboundOutboxEntity row = new InboundOutboxEntity(); row.setId(9001L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index c4242c9fc..dbb9a5567 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -44,6 +44,7 @@ class OverseasContainerEventConsumerTest { void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() { ContainerBookingEntity container = container(); when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container); + when(containers.applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong())).thenReturn(1); Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); @@ -67,10 +68,10 @@ class OverseasContainerEventConsumerTest { EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); existing.setPayloadHash(event.payloadHash()); + existing.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(existing); - when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); - consumer.consume(event); + assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE); verify(containers, never()).applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong()); verify(inbox, never()).insert(any(ContainerEventInboxEntity.class)); @@ -91,6 +92,17 @@ class OverseasContainerEventConsumerTest { verifyNoInteractions(containers, inbox); } + @Test + void unappliedUnloadCannotBeAcknowledgedAsSuccessful() { + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); + EventEnvelope event = event("00000000-0000-4000-8000-000000000004", 7, + new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"), + Instant.parse("2026-08-20T02:30:00Z"), null)); + assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("卸柜事实未写入"); + verify(inbox, never()).updateById(any(ContainerEventInboxEntity.class)); + } + private EventEnvelope event(String eventId, long aggregateVersion, OverseasContainerUnloadedV1 payload) { var node = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java index f065a93ec..945887023 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java @@ -47,7 +47,8 @@ class OverseasInboundOrderDetainedConsumerTest { when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound); when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1); - consumer.consume(event(7, true, "等待查验")); + assertThat(consumer.consume(event(7, true, "等待查验")).outcome()) + .isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED); verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L); ArgumentCaptor history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class); @@ -61,7 +62,8 @@ class OverseasInboundOrderDetainedConsumerTest { void staleReleaseCannotOverwriteNewerHoldState() { when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L)); - consumer.consume(event(7, false, "解除")); + assertThat(consumer.consume(event(7, false, "解除")).outcome()) + .isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.STALE); verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong()); verifyNoInteractions(holdRecords); diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index 0b0626c50..175a78eb1 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -9,7 +9,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; @@ -17,8 +17,9 @@ import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.web.cors.reactive.CorsConfigurationSource; import reactor.core.publisher.Mono; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import java.util.List; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; @@ -44,6 +45,9 @@ public class GatewaySecurityConfig { .access(new AppScopeAuthorizationManager("MERCHANT")); exchanges.pathMatchers("/api/platform/**") .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.pathMatchers("/api/pda/**") + .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.pathMatchers("/internal/**").denyAll(); exchanges.anyExchange().authenticated(); return http.cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(ServerHttpSecurity.CsrfSpec::disable) @@ -59,12 +63,11 @@ public class GatewaySecurityConfig { @Bean ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { - byte[] key = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); - if (key.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder - .withSecretKey(new SecretKeySpec(key, "HmacSHA256")) - .macAlgorithm(MacAlgorithm.HS256).build(); - decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer())); + .withJwkSetUri(properties.jwkSetUri()).jwsAlgorithm(SignatureAlgorithm.RS256).build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.issuer()), + new JwtClaimValidator>("aud", values -> values != null && values.contains(properties.audience())))); return decoder; } diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java index 01268b5d2..8d357b975 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -8,10 +8,11 @@ import java.util.List; * 网关安全配置模型,集中承载 JWT 密钥、签发方、Redis 令牌键前缀和匿名放行路径。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record GatewaySecurityProperties(String jwtSecret, String issuer, String tokenKeyPrefix, +public record GatewaySecurityProperties(String jwkSetUri, String issuer, String audience, String tokenKeyPrefix, List permitPaths) { public GatewaySecurityProperties { issuer = issuer == null || issuer.isBlank() ? "auth-service" : issuer; + audience = audience == null || audience.isBlank() ? "lastleg-api" : audience; tokenKeyPrefix = normalize(tokenKeyPrefix); permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); } diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 2304723e4..c7df24b36 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -42,6 +42,14 @@ spring: enabled: false lower-case-service-id: true routes: + - id: lastleg-service + uri: lb://lastleg-service + predicates: + - Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/** + - id: warehouse-overseas-service + uri: lb://warehouse-overseas-service + predicates: + - Path=/api/platform/v1/overseas-warehouse-resources/** - id: auth-service uri: lb://auth-service predicates: @@ -83,6 +91,18 @@ spring: predicates: - Path=/api/platform/finance/v1/**,/api/client/finance/v1/** # Swagger 文档聚合路由。 + - id: lastleg-openapi + uri: lb://lastleg-service + predicates: + - Path=/lastleg-service/v3/api-docs/** + filters: + - StripPrefix=1 + - id: warehouse-overseas-openapi + uri: lb://warehouse-overseas-service + predicates: + - Path=/warehouse-overseas-service/v3/api-docs/** + filters: + - StripPrefix=1 - id: auth-openapi uri: lb://auth-service predicates: @@ -158,7 +178,9 @@ baiyue: allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} permit-paths: - /api/pda-updates/** @@ -183,6 +205,8 @@ baiyue: - /integration-service/v3/api-docs/** - /event-bridge-service/v3/api-docs/** - /finance-service/v3/api-docs/** + - /lastleg-service/v3/api-docs/** + - /warehouse-overseas-service/v3/api-docs/** management: endpoints: @@ -231,3 +255,7 @@ springdoc: url: /event-bridge-service/v3/api-docs - name: finance url: /finance-service/v3/api-docs + - name: lastleg + url: /lastleg-service/v3/api-docs + - name: warehouse-overseas + url: /warehouse-overseas-service/v3/api-docs diff --git a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java index a6ab80981..bd212811f 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", - "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" + "baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks" }) class CustomerTransitStatisticsRouteContractTest { @Autowired private GatewayProperties gatewayProperties; diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index 8b13163fd..09755984a 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", - "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long", + "baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks", "baiyue.cors.allowed-origins=http://localhost:5173,http://localhost:5174" }) @AutoConfigureWebTestClient diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index c2778a083..e792921e3 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", - "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" + "baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks" }) class GatewayFileRouteConfigurationTest { @Autowired diff --git a/gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java b/gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java new file mode 100644 index 000000000..253571ba7 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java @@ -0,0 +1,55 @@ +package com.baiyue.gateway.config; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.JwtException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class UnifiedJwtDecoderTest { + @Test + void acceptsUnifiedRsaTokenAndRejectsWrongAudienceIssuerAndExpiry() throws Exception { + var key = new RSAKeyGenerator(2048).keyID("current").generate(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/jwks", exchange -> { + byte[] body = new JWKSet(key.toPublicJWK()).toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { output.write(body); } + }); + server.start(); + try { + var properties = new GatewaySecurityProperties("http://127.0.0.1:" + server.getAddress().getPort() + "/jwks", + "auth-service", "lastleg-api", "test:auth", List.of()); + var decoder = new GatewaySecurityConfig().reactiveJwtDecoder(properties); + var signer = new RSASSASigner(key); + String valid = token(signer, "auth-service", "lastleg-api", 120); + assertThat(decoder.decode(valid).block(Duration.ofSeconds(5)).getSubject()).isEqualTo("42"); + for (String invalid : List.of(token(signer, "other", "lastleg-api", 120), + token(signer, "auth-service", "other", 120), token(signer, "auth-service", "lastleg-api", -120))) { + assertThatThrownBy(() -> decoder.decode(invalid).block(Duration.ofSeconds(5))) + .isInstanceOf(JwtException.class); + } + } finally { server.stop(0); } + } + + private String token(RSASSASigner signer, String issuer, String audience, int ttl) throws Exception { + var jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("current").build(), + new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience) + .issueTime(Date.from(Instant.now().minusSeconds(300))) + .expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build()); + jwt.sign(signer); + return jwt.serialize(); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index e1a487122..2f83481d9 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -19,6 +19,10 @@ public interface AuthorizationQueryMapper { AND u.status = 'ACTIVE' AND u.deleted = 0 AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 + AND (m.tenant_id = 0 OR EXISTS ( + SELECT 1 FROM iam_tenant t WHERE t.id = m.tenant_id AND t.deleted = 0 + AND t.status IN ('ACTIVE', 'PROFILE_REQUIRED', 'PENDING', 'REJECTED') + )) """) int countActiveIdentity(@Param("userId") long userId, @Param("tenantId") long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 9433ffab0..93f0dfe29 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -165,7 +165,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 0f0a7809a..f6a849271 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -423,10 +423,30 @@ public class TenantService { } tenant.setStatus(request.status()); repository.saveTenant(tenant); + revokeMerchantSessions(tenantId); securityEvents.tenantStatus(tenantId, request.status()); return response(tenant); } + private void revokeMerchantSessions(long tenantId) { + List accounts = memberRepository.findByTenant(tenantId).stream() + .map(member -> memberRepository.findUser(member.getUserId())) + .filter(java.util.Objects::nonNull).map(IamUser::getAuthUserId) + .filter(java.util.Objects::nonNull).distinct().toList(); + for (int start = 0; start < accounts.size(); start += 200) { + Set batch = Set.copyOf(accounts.subList(start, Math.min(start + 200, accounts.size()))); + try { + ApiResponse result = authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(batch)); + if (result == null || !result.successful()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户会话撤销失败,请重试"); + } + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户会话撤销失败,请重试"); + } + } + } + @Transactional @BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId", description = "平台提交企业客户审核") diff --git a/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java new file mode 100644 index 000000000..d6d8047f4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java @@ -0,0 +1,37 @@ +package com.baiyue.iam.mapper; + +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class AuthorizationIdentitySqlTest { + @Test + void disabledArchivedDeletedOrMissingCustomerCannotGetNewAuthorizationSnapshot() throws Exception { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE iam_user(id BIGINT, auth_user_id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_tenant(id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)"); + String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("countActiveIdentity", long.class, long.class, long.class) + .getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7"); + for (String status : new String[]{"ACTIVE", "PROFILE_REQUIRED", "PENDING", "REJECTED"}) { + jdbc.update("UPDATE iam_tenant SET status=?", status); + assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isEqualTo(1); + } + for (String status : new String[]{"DISABLED", "ARCHIVED", "UNKNOWN"}) { + jdbc.update("UPDATE iam_tenant SET status=?", status); + assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isZero(); + } + jdbc.update("UPDATE iam_tenant SET status='ACTIVE', deleted=1"); + assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + jdbc.update("DELETE FROM iam_tenant"); + assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + jdbc.update("UPDATE iam_membership SET tenant_id=0"); + assertThat(jdbc.queryForObject(sql.replace("m.tenant_id = 7", "m.tenant_id = 0"), Integer.class)).isEqualTo(1); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 67b7cfc39..f9e96fda5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -201,6 +201,18 @@ class PlatformUserManagementServiceTest { "PLATFORM_USER_AUTHORIZATION_CHANGED"); } + @Test + void changingNameTogetherWithWarehouseStillRevokesSessions() { + when(auth.profile(eq("internal-token"), eq(10L), any())).thenReturn(ApiResponse.success(null)); + platformRole.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + membership.setOverseasWarehouseId(900L); + service.update(30L, new UpdateUserRequest("新姓名", "13800138000", null, + 0L, List.of(40001L), 901L), Set.of("platform:user:grant-role")); + verify(auth).profile(eq("internal-token"), eq(10L), any()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + @Test void clearingWarehouseBindingAlsoRevokesAllAuthSessions() { platformRole.setRoleCode("WAREHOUSE_OPERATOR"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 4b8e6074e..28eb7d4de 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -181,6 +181,30 @@ class TenantServiceTest { verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } + @Test + void disablingCustomerRevokesEveryMemberAccountAndRejectsFailedRevocation() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + when(repository.findTenant(10001L)).thenReturn(tenant); + IamMembership first = new IamMembership(); first.setUserId(1L); + IamMembership second = new IamMembership(); second.setUserId(2L); + IamUser firstUser = new IamUser(); firstUser.setAuthUserId(101L); + IamUser secondUser = new IamUser(); secondUser.setAuthUserId(102L); + when(members.findByTenant(10001L)).thenReturn(List.of(first, second)); + when(members.findUser(1L)).thenReturn(firstUser); + when(members.findUser(2L)).thenReturn(secondUser); + var request = new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(101L, 102L)); + when(authClient.revokeSessions("internal-token", request)).thenReturn(null); + assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"))); + verify(securityEvents, never()).tenantStatus(org.mockito.ArgumentMatchers.anyLong(), any()); + + when(authClient.revokeSessions("internal-token", request)) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(null)); + assertEquals("DISABLED", service.changeMerchantStatus(10001L, + new ChangeTenantStatusRequest("DISABLED")).status()); + verify(securityEvents).tenantStatus(10001L, "DISABLED"); + } + @Test void activeCustomerCanChangeCompanyContactPhoneWithoutChangingAnyLoginIdentity() { IamTenant tenant = merchant(10001L, "ACTIVE"); diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 1314eb0d7..4c9968de3 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -22,6 +22,7 @@ org.springframework.bootspring-boot-starter-testtest com.tngtech.archunitarchunittest com.belerwebpinyin4j2.5.1 + com.h2databaseh2test org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java new file mode 100644 index 000000000..4aa290029 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** Dispatches each supported business event directly to its owning service. */ +@Component +@RequiredArgsConstructor +public class DirectEventClient { + private final FirstlegEventClient firstleg; + private final FinanceBusinessEventClient finance; + + public ApiResponse receive(String token, EventEnvelope event) { + event.requireValid(); + return switch (event.eventType()) { + case EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event); + case EventTypes.WALLET_COMMAND_REQUESTED -> finance.wallet(token, event); + case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> finance.fee(token, event); + default -> throw new IllegalArgumentException("Unsupported outbound event type: " + event.eventType()); + }; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java deleted file mode 100644 index 5a09c0bc4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.baiyue.lastleg.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -/** 将海外业务 Outbox 投递到国内区域事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", - url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}") -public interface EventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive( - @RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java new file mode 100644 index 000000000..96e35de55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "finance-service", contextId = "FinanceBusinessEventClient", + url = "${FINANCE_SERVICE_URL:${baiyue.clients.finance.url:}}") +public interface FinanceBusinessEventClient { + @PostMapping("/internal/finance/v1/wallet-command-events") + ApiResponse wallet(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + @PostMapping("/internal/finance/v1/business-fee-source-events") + ApiResponse fee(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java new file mode 100644 index 000000000..d3a07830a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "firstleg-service", contextId = "FirstlegEventClient", + url = "${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}") +public interface FirstlegEventClient { + @PostMapping("/internal/firstleg/v1/container-events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 1b283ce63..e7312a6f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; @@ -60,11 +61,11 @@ public class LastlegOrderController { @PostMapping("/internal/lastleg/events") @Operation(summary = "Receive a first-leg loading or actual shipping-node event", description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.") - public ApiResponse receive( + public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); - int consumed; + DeliveryAck consumed; if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { consumed = shippingNodeConsumer.consume(event); } else if (EventTypes.WALLET_COMMAND_COMPLETED.equals(event.eventType())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java index c1d28ee5a..d37fab88b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -27,8 +27,11 @@ public class OverseasFeeOutboxRepository { public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) { return mapper.update(null, new LambdaUpdateWrapper() .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing -> + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) + .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") .set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker) .set(LastlegEventOutbox::getLockedAt, now)) == 1; } @@ -40,6 +43,12 @@ public class OverseasFeeOutboxRepository { mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); } + public void dead(long id, String worker, int failures, String error) { + mapper.update(null, claimed(id, worker, "DEAD") + .set(LastlegEventOutbox::getRetryCount, failures) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) { mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) .set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java index 5ec65a694..571f6c90d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -1,7 +1,8 @@ package com.baiyue.lastleg.financefee.service; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,7 +11,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.lang.management.ManagementFactory; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.UUID; @@ -18,28 +18,45 @@ import java.util.UUID; @Component public class OverseasFeeOutboxRelay { @Resource private OverseasFeeOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String token; - private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID(); + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; + + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}") public void relay() { + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); - for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) { - if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue; + for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) { + String worker = UUID.randomUUID().toString(); + now = LocalDateTime.now(ZoneOffset.UTC); + + if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"); + continue; + } try { EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); var response = client.receive(token, envelope); - if (response == null || !"200".equals(response.code()) || response.data() == null - || !envelope.eventId().equals(response.data().eventId())) - throw new IllegalStateException("Event Bridge未确认海外费用事件"); + if (response == null || !response.successful() || response.data() == null) + throw new IllegalStateException("Finance未确认费用事件"); + response.data().requireMatches(envelope); repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); } catch (Exception exception) { - int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1; - repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage()); + int retries = failures + 1; + if (policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, exception.getMessage()); + else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), exception.getMessage()); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index b7988f67d..2cbf52ffb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -1,8 +1,10 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,26 +17,40 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */ +/** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */ @Component @Slf4j public class InboundUnloadCompletedOutboxRelay { @Resource private UnloadCompletedOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; - @Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds; - private final String workerId = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; @Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); LocalDateTime now = LocalDateTime.now(); - LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10)); + LocalDateTime expiredLeaseBefore = now.minusSeconds(leaseSeconds); for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { + String workerId = UUID.randomUUID().toString(); + now = LocalDateTime.now(); + expiredLeaseBefore = now.minusSeconds(leaseSeconds); + if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); if (row == null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + continue; + } try { EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); EventEnvelopeValidator.requireValid(event); @@ -42,7 +58,8 @@ public class InboundUnloadCompletedOutboxRelay { requireAcknowledgement(event, response); repository.published(row.getId(), workerId, LocalDateTime.now()); } catch (Exception exception) { - repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), + if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, exception.getMessage()); + else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), exception.getMessage()); log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage()); } @@ -50,16 +67,13 @@ public class InboundUnloadCompletedOutboxRelay { } private void requireAcknowledgement(EventEnvelope event, - com.baiyue.common.core.api.ApiResponse response) { - if (response == null) throw new IllegalStateException("国内事件桥返回空响应"); + com.baiyue.common.core.api.ApiResponse response) { + if (response == null) throw new IllegalStateException("消费服务返回空响应"); if (!response.successful()) { - throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code() + throw new IllegalStateException("消费服务拒绝海外事件: code=" + response.code() + ", message=" + response.message() + ", requestId=" + response.requestId()); } - if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据"); - if (!event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId() - + ", actual=" + response.data().eventId()); - } + if (response.data() == null) throw new IllegalStateException("消费服务成功响应缺少确认数据"); + response.data().requireMatches(event); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 279b9e728..bbce4b4b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -41,6 +41,7 @@ public class UnloadCompletedOutboxRepository { .or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") .set(LastlegEventOutbox::getStatus, "PROCESSING") .set(LastlegEventOutbox::getLockedBy, worker) .set(LastlegEventOutbox::getLockedAt, now)) == 1; @@ -64,6 +65,14 @@ public class UnloadCompletedOutboxRepository { .set(LastlegEventOutbox::getLastError, null)); } + public void dead(long id, String worker, int failures, String error) { + mapper.update(null, claimedBase(id, worker) + .set(LastlegEventOutbox::getStatus, "DEAD") + .set(LastlegEventOutbox::getRetryCount, failures) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 15e448879..d3ca0bc7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -51,7 +51,7 @@ public class LoadingCompletedConsumer { @Resource private ContainerLoadingEvidenceMapper loadingEvidence; @Transactional(isolation = Isolation.READ_COMMITTED) - public int consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3)) @@ -72,7 +72,8 @@ public class LoadingCompletedConsumer { LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); - return countOrders(envelope,containerNo); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } LastlegEventInbox inbox=new LastlegEventInbox(); @@ -85,7 +86,8 @@ public class LoadingCompletedConsumer { || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("事件摘要冲突", exception); } - return countOrders(envelope,containerNo); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } projectLoadingEvidence(envelope, sourceContainerBookingId); @@ -102,7 +104,7 @@ public class LoadingCompletedConsumer { sourceContainerBookingId, owner); } inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); - return payload.orders().size(); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index a0787bdce..e140c2cee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.shipping.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -28,7 +29,7 @@ public class ContainerShippingNodeConsumer { @Resource private ObjectMapper objectMapper; @Transactional(isolation = Isolation.READ_COMMITTED) - public int consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { validate(envelope); FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope); validateEnvelopePayload(envelope, payload); @@ -37,7 +38,8 @@ public class ContainerShippingNodeConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("船节点事件摘要冲突"); } - return 1; + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } LastlegEventInbox inbox = inbox(envelope); try { inboxes.insert(inbox); } @@ -46,7 +48,8 @@ public class ContainerShippingNodeConsumer { if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("船节点事件摘要冲突", raced); } - return 1; + if (!"PROCESSED".equals(concurrent.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } gates.initialize(payload.containerBookingId(), payload.oceanBookingId(), @@ -55,13 +58,13 @@ public class ContainerShippingNodeConsumer { if (gate == null || !payload.containerNo().equals(gate.getContainerNo())) { throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); } - apply(gate, envelope, payload); + boolean applied = apply(gate, envelope, payload); gates.updateById(gate); inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox); - return 1; + return DeliveryAck.of(envelope, applied ? DeliveryAck.Outcome.APPLIED : DeliveryAck.Outcome.STALE); } - private void apply(ContainerShippingGate gate, EventEnvelope envelope, + private boolean apply(ContainerShippingGate gate, EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion(); int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence(); @@ -88,6 +91,7 @@ public class ContainerShippingNodeConsumer { gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(), envelope.handoverVersion())); gate.setUpdatedAt(LocalDateTime.now()); + return accepted; } private void validate(EventEnvelope envelope) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java index a95dd7e06..78a9ad765 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.wallet; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -26,23 +27,27 @@ public class WalletCommandCompletedConsumer { private final ObjectMapper objectMapper; private final List handlers; - @Transactional - public int consume(EventEnvelope envelope) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope envelope) { WalletCommandCompletedV1 result = validate(envelope); - List existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId()); + List existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=? AND status='PROCESSED'", String.class, envelope.eventId()); if (!existing.isEmpty()) { if (!existing.get(0).equals(envelope.payloadHash())) throw bad("同一资金结果事件ID对应不同载荷"); - return 0; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } try { jdbc.update("INSERT INTO ll_wallet_event_inbox(event_id,command_event_id,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'RECEIVED',NOW(3))", envelope.eventId(), result.commandEventId(), envelope.payloadHash(), json(envelope)); - } catch (DuplicateKeyException exception) { return 0; } + } catch (DuplicateKeyException exception) { + List raced = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=? AND status='PROCESSED'", String.class, envelope.eventId()); + if (raced.size() != 1 || !raced.get(0).equals(envelope.payloadHash())) throw bad("资金结果事件身份冲突或尚未完成"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + } WalletResultHandler handler = handlers.stream().filter(value -> value.supports(result.businessType())).findFirst() .orElseThrow(() -> bad("没有可处理该资金结果的业务模块")); handler.handle(result); jdbc.update("UPDATE ll_wallet_event_inbox SET status='PROCESSED',processed_at=NOW(3) WHERE event_id=?", envelope.eventId()); - return 1; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private WalletCommandCompletedV1 validate(EventEnvelope envelope) { diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 07e47ba18..6164da56a 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -88,10 +88,9 @@ baiyue: lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: - gateway-internal-service-token: ${GATEWAY_INTERNAL_SERVICE_TOKEN:${INTERNAL_SERVICE_TOKEN:}} internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java new file mode 100644 index 000000000..2235aa467 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import java.time.Instant; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DirectEventClientTest { + private final FirstlegEventClient firstleg = mock(FirstlegEventClient.class); + private final FinanceBusinessEventClient finance = mock(FinanceBusinessEventClient.class); + private final DirectEventClient client = new DirectEventClient(firstleg, finance); + + @Test void walletIsSentOnlyToFinance() { + var event = event(EventTypes.WALLET_COMMAND_REQUESTED); + var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + when(finance.wallet("token", event)).thenReturn(ack); + assertThat(client.receive("token", event)).isSameAs(ack); + verify(finance).wallet("token", event); + verifyNoMoreInteractions(finance); + verifyNoInteractions(firstleg); + } + + @Test void feeIsSentOnlyToFinance() { + var event = event(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + client.receive("token", event); + verify(finance).fee("token", event); + verifyNoMoreInteractions(finance); + verifyNoInteractions(firstleg); + } + + @Test void fulfillmentEventsAreSentOnlyToFirstleg() { + for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { + var event = event(type); + client.receive("token", event); + verify(firstleg).receive("token", event); + } + verifyNoInteractions(finance); + } + + @Test void unsupportedEventsHaveNoDefaultDestination() { + assertThatThrownBy(() -> client.receive("token", event(EventTypes.WALLET_COMMAND_COMPLETED))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unsupported outbound"); + verifyNoInteractions(firstleg, finance); + } + + @Test void declaredEndpointsMatchTheSharedRouteTable() throws Exception { + assertEndpoint(FirstlegEventClient.class, "receive", "FIRSTLEG_SERVICE_URL", "firstleg", + EventTypes.OVERSEAS_CONTAINER_UNLOADED); + assertEndpoint(FinanceBusinessEventClient.class, "wallet", "FINANCE_SERVICE_URL", "finance", + EventTypes.WALLET_COMMAND_REQUESTED); + assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance", + EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + } + + private void assertEndpoint(Class type, String method, String env, String property, String eventType) throws Exception { + var route = EventDeliveryRoute.forEventType(eventType); + var annotation = type.getAnnotation(FeignClient.class); + assertThat(annotation.name()).isEqualTo(route.serviceName()); + assertThat(annotation.url()).isEqualTo("${" + env + ":${baiyue.clients." + property + ".url:}}"); + assertThat(type.getMethod(method, String.class, EventEnvelope.class).getAnnotation(PostMapping.class).value()) + .containsExactly(route.path()); + } + + private EventEnvelope event(String type) { + var payload = new ObjectMapper().createObjectNode().put("businessNo", "test"); + return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), + RegionCode.OVERSEAS, RegionCode.CN, "1", "BUSINESS", "1", 1L, null, + "trace", "correlation", null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java deleted file mode 100644 index 5891459d7..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.lastleg.client; - -import org.junit.jupiter.api.Test; -import org.springframework.cloud.openfeign.FeignClient; - -import static org.assertj.core.api.Assertions.assertThat; - -class EventBridgeClientContractTest { - @Test - void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() { - FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class); - - assertThat(contract.url()) - .isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java new file mode 100644 index 000000000..3291ef092 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.lastleg.client.DirectEventClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasFeeOutboxRetryTest { + @Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() { + var repository = mock(OverseasFeeOutboxRepository.class); + var client = mock(DirectEventClient.class); + var relay = new OverseasFeeOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", new ObjectMapper()); + var row = new LastlegEventOutbox(); + row.setId(1L); row.setRetryCount(11); row.setPayloadJson("invalid-json"); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(1L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(1L), anyString())).thenReturn(row); + relay.relay(); + row.setRetryCount(12); + relay.relay(); + verify(repository, times(2)).dead(eq(1L), anyString(), eq(12), anyString()); + verify(repository, never()).retry(anyLong(), anyString(), anyInt(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + verifyNoInteractions(client); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 472331057..49d3ee511 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -2,12 +2,13 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.RegionCode; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,7 +31,7 @@ import com.baiyue.common.core.api.ResultCode; @ExtendWith(MockitoExtension.class) class InboundUnloadCompletedOutboxRelayTest { @Mock private UnloadCompletedOutboxRepository repository; - @Mock private EventBridgeClient client; + @Mock private DirectEventClient client; private ObjectMapper objectMapper; private InboundUnloadCompletedOutboxRelay relay; @@ -43,19 +44,18 @@ class InboundUnloadCompletedOutboxRelayTest { ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); - ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + ReflectionTestUtils.setField(relay, "baseDelaySeconds", 5L); } @Test - void marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception { + void marksOutboxPublishedAfterConsumerAcknowledgesSameEvent() throws Exception { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) - .thenReturn(ApiResponse.success(new EventBridgeClient.Acknowledgement( - event.eventId(), "已接收", "2026-08-20T03:01:00"))); + .thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED))); relay.relay(); @@ -64,23 +64,23 @@ class InboundUnloadCompletedOutboxRelayTest { } @Test - void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception { + void releasesLeaseForRetryWhenConsumerDeliveryFails() throws Exception { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) - .thenThrow(new IllegalStateException("bridge unavailable")); + .thenThrow(new IllegalStateException("consumer unavailable")); relay.relay(); - verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable")); + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("consumer unavailable")); verify(repository, never()).published(anyLong(), anyString(), any()); } @Test - void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() throws Exception { + void preservesConsumerBusinessErrorForDeploymentDiagnosis() throws Exception { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); @@ -88,15 +88,58 @@ class InboundUnloadCompletedOutboxRelayTest { when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST, - "事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge")); + "消费事件类型不支持", "request-bridge", "trace-bridge")); relay.relay(); verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), - contains("事件目标区域与当前事件桥区域不一致")); + contains("消费事件类型不支持")); verify(repository, never()).published(anyLong(), anyString(), any()); } + @Test + void mismatchedHashOrUnallowedStaleCannotMarkPublished() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenReturn(ApiResponse.success(new DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), DeliveryAck.Outcome.APPLIED))) + .thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.STALE))); + relay.relay(); + relay.relay(); + verify(repository, times(2)).retry(eq(9001L), anyString(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void twelfthFailureGoesDeadInsteadOfSchedulingAnotherRetry() throws Exception { + LastlegEventOutbox row = row(event()); + row.setRetryCount(11); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); + relay.relay(); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("consumer unavailable")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void recoveredExhaustedLeaseDoesNotSendAgain() throws Exception { + LastlegEventOutbox row = row(event()); + row.setRetryCount(12); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + relay.relay(); + verifyNoInteractions(client); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("租约超时")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java new file mode 100644 index 000000000..5c9b6f049 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class OutboxLeaseDatabaseTest { + @Test void recoveredLeasesCountFailuresAndOldOwnersCannotChangeNewState() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_type VARCHAR(64),event_version INT,event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(LastlegEventOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + try (var session = factory.getObject().openSession(true)) { + var mapper = session.getMapper(LastlegEventOutboxMapper.class); + var unload = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(unload, "mapper", mapper); + var fee = new OverseasFeeOutboxRepository(mapper); + LocalDateTime now = LocalDateTime.now(); + for (long id : new long[]{1, 2}) { + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(?,?,'PENDING',0,?,?)", + id, id == 1 ? EventTypes.OVERSEAS_CONTAINER_UNLOADED : EventTypes.BUSINESS_FEE_SOURCE_CHANGED, + now.minusSeconds(1), now.minusMinutes(2)); + boolean first = id == 1 ? unload.claim(id, "old", now, now.minusSeconds(60)) : fee.claim(id, "old", now, now.minusSeconds(60)); + assertThat(first).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isZero(); + jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=?", now.minusSeconds(61), id); + boolean second = id == 1 ? unload.claim(id, "new", now, now.minusSeconds(60)) : fee.claim(id, "new", now, now.minusSeconds(60)); + assertThat(second).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isEqualTo(1); + if (id == 1) unload.dead(id, "old", 12, "late failure"); else fee.dead(id, "old", 12, "late failure"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("PROCESSING"); + if (id == 1) unload.dead(id, "new", 12, "exhausted"); else fee.dead(id, "new", 12, "exhausted"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("DEAD"); + boolean third = id == 1 ? unload.claim(id, "third", now, now.minusSeconds(60)) : fee.claim(id, "third", now, now.minusSeconds(60)); + assertThat(third).isFalse(); + } + jdbc.update("UPDATE ll_event_outbox SET status='PENDING',next_retry_at=? WHERE id=2", now.plusMinutes(5)); + assertThat(fee.claim(2L, "early", now, now.minusSeconds(60))).isFalse(); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java index b2123ef9e..24ad3ccf8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -18,6 +18,19 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class UnloadCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void feeClaimAlsoRechecksRetryWindow() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), LastlegEventOutbox.class); + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); + assertThat(repository.claim(1L, "attempt", now, now.minusMinutes(1))).isTrue(); + assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status", "locked_at"); + } + @Test @SuppressWarnings({"rawtypes", "unchecked"}) void claimRechecksRetryWindowAfterCandidateSelection() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 4eb28750f..16dd17461 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -5,8 +5,8 @@ class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } @@ -14,8 +14,8 @@ class LoadingCompletedConsumerTest { @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } - @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } + @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } + @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest { assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void freezesReceivingWarehouseInInitialInTransitInventory(){ service.consume(event()); ArgumentCaptor captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); @@ -42,7 +42,7 @@ class LoadingCompletedConsumerTest { var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",2,30,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","MSCU6639870","CAB-MULTI","92","Warehouse",List.of(order)),2); - assertEquals(1,service.consume(event)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); verify(orders,times(1)).insert(any(LastlegOrder.class)); ArgumentCaptor detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class); verify(details,times(2)).insert(detailCaptor.capture()); @@ -67,7 +67,7 @@ class LoadingCompletedConsumerTest { EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(orderCaptor.capture()); assertTrue(orderCaptor.getAllValues().stream().allMatch(order->order.getFbaWarehouseCode()==null)); @@ -100,7 +100,7 @@ class LoadingCompletedConsumerTest { EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){ @@ -111,7 +111,7 @@ class LoadingCompletedConsumerTest { EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ @@ -125,7 +125,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); @@ -248,7 +248,7 @@ class LoadingCompletedConsumerTest { for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(event)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class); verify(loadingEvidence,times(5)).insert(captured.capture()); assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet())); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 179433b94..99c59b069 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -96,7 +96,7 @@ class TaillegRealConsumerAcceptanceTest { String envelopeJson = jdbc.queryForObject( "SELECT payload_json FROM baiyue_firstleg.fl_inbound_outbox WHERE event_key=?", String.class, EVENT_KEY); EventEnvelope envelope = json.readValue(envelopeJson, EventEnvelope.class); - assertEquals(2, consumer.consume(envelope)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, consumer.consume(envelope).outcome()); Long sourceOrderId = jdbc.queryForObject("SELECT id FROM ll_order WHERE customer_id=? AND " + "source_loading_plan_id='990022022' AND order_type='FIRST_LEG'", Long.class, CUSTOMER); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 2df7d6fb5..6c73a9d5b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -50,7 +50,7 @@ class ContainerShippingNodeConsumerTest { EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174101", "PICKUP_READY", 80, 5, Instant.parse("2026-08-21T10:00:00Z")); - assertThat(consumer.consume(event)).isEqualTo(1); + assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED); assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE"); assertThat(gate.getNodeSequence()).isEqualTo(80); @@ -86,9 +86,9 @@ class ContainerShippingNodeConsumerTest { void eventIdIsIdempotentByHashAndRejectsDifferentHash() { EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174104", "PICKUP_READY", 80, 5, Instant.parse("2026-08-21T10:00:00Z")); - LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash()); + LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash()); existing.setStatus("PROCESSED"); when(inboxes.selectById(event.eventId())).thenReturn(existing); - assertThat(consumer.consume(event)).isEqualTo(1); + assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE); verify(gates, never()).updateById(any(ContainerShippingGate.class)); existing.setPayloadHash("sha256:" + "0".repeat(64)); diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 5451d495a..44cbc3fa2 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -905,8 +905,12 @@ Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、 Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口' Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口' Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界' -Assert-Contains $businessIndex '海外 Token 不携带角色或权限集' '根海外小 Token 边界' -Assert-Contains $backendAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '后端海外小 Token 边界' +Assert-Contains $businessIndex '统一认证与服务通信契约.md' '根统一认证契约入口' +Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端统一认证目标' +$unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md') +Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界' +Assert-Contains $unifiedCommunication '平台/客户/PDA' '统一认证访问域隔离' +Assert-Contains $unifiedCommunication '整体迁移尚未完成' '认证目标与落地状态分离' Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影' Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付' @@ -973,7 +977,7 @@ $criticalPaths = @( 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts', - 'apps/client-web/src/api/overseasHttp.ts', + 'apps/client-web/src/api/http.ts', 'apps/client-web/src/modules/overseas-warehouse/routes.ts', 'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts', 'apps/client-web/src/modules/overseas-warehouse/views/WarehouseServiceView.vue', @@ -1080,7 +1084,7 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs', 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', - 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', diff --git a/scripts/gateway-route-contract.test.mjs b/scripts/gateway-route-contract.test.mjs index 75b40ca37..73470f076 100644 --- a/scripts/gateway-route-contract.test.mjs +++ b/scripts/gateway-route-contract.test.mjs @@ -80,7 +80,6 @@ function verify(path, service, table, file) { } const domestic = routes(read('gateway/src/main/resources/application.yml')) -const overseas = routes(read('gateway-overseas/src/main/resources/application-overseas.yml')) const modules = [...read('pom.xml').matchAll(/([^<]+)<\/module>/g)].map(m => m[1]) test('local regression and root CI require route checks before Maven/dispatch', () => { @@ -101,8 +100,7 @@ test('Controller prefixes route to their owning service; internal prefixes stay if (!existsSync(resolve(root, app))) continue const service = read(app).match(/application:\s*\r?\n\s+name:\s*([\w-]+)/)?.[1] assert.ok(service, `Missing application name: ${module}`) - const tables = ['lastleg-service', 'warehouse-overseas-service'].includes(module) - ? [overseas] : ['file', 'print'].includes(module) ? [domestic, overseas] : [domestic] + const tables = [domestic] for (const file of javaFiles(resolve(root, module, 'src/main/java'))) { for (const path of controllerPaths(readFileSync(file, 'utf8'), file)) { // PDA release metadata lives in the domestic File control plane. Both diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java index b930b438e..e2efe6836 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java @@ -9,30 +9,23 @@ import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.service.WarehouseResourceService; -import com.baiyue.warehouse.service.DomesticWarehouseDirectory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; -import java.util.List; - -/** Exposes the domestic authoritative directory as read-only overseas resource options. */ +/** Cross-warehouse queries for overseas resources; warehouse masters belong to Warehouse. */ @RestController -@RequestMapping("/api/platform/v1/warehouse-admin") +@RequestMapping("/api/platform/v1/overseas-warehouse-resources") @Tag(name = "Platform overseas warehouse directory") public class PlatformWarehouseDirectoryController { - private final DomesticWarehouseDirectory directory; private final WarehouseResourceService resources; - public PlatformWarehouseDirectoryController(DomesticWarehouseDirectory directory, - WarehouseResourceService resources) { - this.directory = directory; + public PlatformWarehouseDirectoryController(WarehouseResourceService resources) { this.resources = resources; } @@ -56,29 +49,6 @@ public class PlatformWarehouseDirectoryController { return ApiResponse.success(resources.publicPalletSummary(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); } - @GetMapping - @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") - @Operation(summary = "Page shared overseas receiving warehouses", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST) - public ApiResponse> warehouses( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(defaultValue = "1") long page, - @RequestParam(defaultValue = "200") long size) { - requirePlatform(user); - if (page < 1 || size < 1 || size > 200) { - throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); - } - List all = directory.options().stream() - .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), - value.warehouseName(), "OVERSEAS", value.status())) - .toList(); - long from = Math.min((page - 1) * size, all.size()); - long to = Math.min(from + size, all.size()); - long pages = all.isEmpty() ? 0 : (all.size() + size - 1) / size; - return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), - page, size, all.size(), pages)); - } - private static void requirePlatform(AuthenticatedUser user) { if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { @@ -86,6 +56,4 @@ public class PlatformWarehouseDirectoryController { } } - public record WarehouseOption(long id, long customerId, String warehouseCode, - String warehouseName, String warehouseType, String status) { } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index f2864e1d9..e081ca4f5 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -33,7 +33,7 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController -@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +@RequestMapping("/api/platform/v1/overseas-warehouse-resources/{warehouseId}") @Tag(name = "Platform overseas warehouse resources") public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java deleted file mode 100644 index 795623301..000000000 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.core.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; - -/** - * 仓库微服务可用性接口,向平台端暴露服务逻辑状态。 - */ -@Tag(name = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。") -@RestController -@RequestMapping("/api/platform/v1/warehouses") -public class WarehouseHealthController { - @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-overseas-service. / 返回 warehouse-overseas-service 的逻辑运行状态。") - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") - @GetMapping("/status") - public ApiResponse> status() { - return ApiResponse.success(Map.of("service", "warehouse-overseas-service", "status", "UP")); - } -} - diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 5cf4f5762..077cf099c 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -51,7 +51,7 @@ baiyue: token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} security: jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml index 8152ade7a..f373d8197 100644 --- a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml +++ b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml @@ -3,7 +3,7 @@ info: title: Overseas pallet label version: 1.0.0 paths: - /api/platform/v1/warehouse-admin/{warehouseId}/pallets/{id}/label: + /api/platform/v1/overseas-warehouse-resources/{warehouseId}/pallets/{id}/label: get: operationId: printOverseasPalletLabel summary: 下载指定托盘的一页标签 PDF diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java deleted file mode 100644 index ae3f2b09e..000000000 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.warehouse.dto.SharedWarehouseReference; -import com.baiyue.warehouse.service.DomesticWarehouseDirectory; -import com.baiyue.warehouse.service.WarehouseResourceService; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class PlatformWarehouseDirectoryControllerTest { - @Test - void pagesPublicSharedWarehouseOptions() { - DomesticWarehouseDirectory directory = mock(DomesticWarehouseDirectory.class); - when(directory.options()).thenReturn(List.of( - new SharedWarehouseReference(21L, "US-LAX", "洛杉矶仓", "ACTIVE"))); - var controller = new PlatformWarehouseDirectoryController(directory, mock(WarehouseResourceService.class)); - var user = new AuthenticatedUser(1L, 0L, 0L, 2L, - "tester", "PLATFORM", "PLATFORM", Set.of()); - - var page = controller.warehouses(user, 1, 200).data(); - - assertEquals(1, page.total()); - assertEquals(21L, page.records().get(0).id()); - assertEquals(0L, page.records().get(0).customerId()); - assertEquals("OVERSEAS", page.records().get(0).warehouseType()); - } -} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 3448dfe3f..a7ecbcb66 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -34,10 +34,10 @@ class WarehouseResourcePermissionContractTest { } @Test - void warehouseDirectoryUsesCanonicalListAuthority() throws Exception { + void crossWarehousePalletDirectoryUsesCanonicalListAuthority() throws Exception { assertPermission(PlatformWarehouseDirectoryController.class.getDeclaredMethod( - "warehouses", com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class), - PREFIX + "warehouse:list"); + "pallets", com.baiyue.common.security.AuthenticatedUser.class, com.baiyue.warehouse.dto.PalletRequests.QueryRequest.class), + PREFIX + "pallet:list"); } private static void assertPermission(Method method, String authority) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java new file mode 100644 index 000000000..cd6b55f62 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java @@ -0,0 +1,51 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.service.WarehouseApplicationService; +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.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** Read-only resource options, without granting warehouse master maintenance permissions. */ +@RestController +public class PlatformOverseasReceivingOptionsController { + private final WarehouseApplicationService service; + + public PlatformOverseasReceivingOptionsController(WarehouseApplicationService service) { + this.service = service; + } + + @GetMapping("/api/platform/v1/warehouse-admin/overseas-receiving-options") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:warehouse:list')") + @Operation(summary = "Page active shared overseas receiving warehouse options", + description = "Requires platform:overseas-warehouse:warehouse:list; only public OVERSEAS/ACTIVE/RECEIVING warehouses.") + public ApiResponse> warehouses( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") long page, + @RequestParam(defaultValue = "200") long size) { + if (user == null || !"PLATFORM".equals(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + if (page < 1 || size < 1 || size > 200) throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); + List all = service.activeSharedOverseasWarehouses().stream() + .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), + value.warehouseName(), "OVERSEAS", value.status())).toList(); + long pages = (all.size() + size - 1) / size; + long from = page > pages ? all.size() : (page - 1) * size; + long to = Math.min(from + size, all.size()); + return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), page, size, all.size(), pages)); + } + + public record WarehouseOption(long id, long customerId, String warehouseCode, + String warehouseName, String warehouseType, String status) { } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java new file mode 100644 index 000000000..e9a7ffc03 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java @@ -0,0 +1,46 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PlatformOverseasReceivingOptionsControllerTest { + private final WarehouseApplicationService service = mock(WarehouseApplicationService.class); + private final PlatformOverseasReceivingOptionsController controller = new PlatformOverseasReceivingOptionsController(service); + private final AuthenticatedUser platform = new AuthenticatedUser(1L, 0L, 0L, 2L, + "tester", "PLATFORM", "PLATFORM", Set.of()); + + @Test + void pagesAuthoritativeSharedOptionsWithoutExposingMasterWrites() throws Exception { + when(service.activeSharedOverseasWarehouses()).thenReturn(List.of( + new SharedOverseasWarehouseResponse(21L, "US-LAX", "洛杉矶仓", "US", "ACTIVE"))); + var page = controller.warehouses(platform, 1, 200).data(); + assertEquals(1, page.total()); + assertEquals(21L, page.records().get(0).id()); + assertEquals(0L, page.records().get(0).customerId()); + assertEquals("OVERSEAS", page.records().get(0).warehouseType()); + assertEquals("hasAuthority('platform:overseas-warehouse:warehouse:list')", + controller.getClass().getMethod("warehouses", AuthenticatedUser.class, long.class, long.class) + .getAnnotation(PreAuthorize.class).value()); + assertTrue(controller.warehouses(platform, Long.MAX_VALUE, 200).data().records().isEmpty()); + } + + @Test + void rejectsInvalidScopeAndPaginationBeforeReadingDirectory() { + var merchant = new AuthenticatedUser(2L, 31L, 31L, 3L, "client", "MERCHANT", "MERCHANT", Set.of()); + assertThrows(BizException.class, () -> controller.warehouses(merchant, 1, 200)); + assertThrows(BizException.class, () -> controller.warehouses(null, 1, 200)); + assertThrows(BizException.class, () -> controller.warehouses(platform, 0, 200)); + assertThrows(BizException.class, () -> controller.warehouses(platform, 1, 201)); + verifyNoInteractions(service); + } +} -- 2.54.0 From 2c60a29e0f732b30cc5ea0da85f51a7527ed5c9b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:06:18 +0800 Subject: [PATCH 010/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-core/AGENTS.md | 1 + .../EventDependencyNotReadyException.java | 7 ++ .../common/event/EventEnvelopeValidator.java | 2 +- .../common/event/EventProtocolException.java | 7 ++ common-web/AGENTS.md | 1 + .../common/web/EventDeliveryFailure.java | 55 +++++++++++++ .../common/web/EventDeliveryFailureTest.java | 55 +++++++++++++ .../BusinessFeeSourceEventService.java | 3 + .../service/WalletCommandEventService.java | 3 + .../service/WalletResultOutboxRelay.java | 18 +++-- .../WalletCommandEventServiceTest.java | 12 +++ .../EventConsumptionExceptionHandler.java | 21 +++++ ...ernalOverseasContainerEventController.java | 2 +- .../service/LoadingCompletedOutboxRelay.java | 37 +++------ .../OverseasContainerEventConsumer.java | 26 +++--- .../OverseasDeliveryCompletedConsumer.java | 23 +++--- .../OverseasInboundOrderDetainedConsumer.java | 20 ++--- .../EventConsumptionExceptionHandlerTest.java | 22 +++++ .../lastleg/client/DirectEventClient.java | 15 +++- .../EventConsumptionExceptionHandler.java | 21 +++++ .../controller/LastlegOrderController.java | 2 +- .../service/OverseasFeeOutboxRelay.java | 14 +++- .../InboundUnloadCompletedOutboxRelay.java | 20 ++--- .../service/LoadingCompletedConsumer.java | 80 +++++++++---------- .../ContainerShippingNodeConsumer.java | 19 +++-- .../WalletCommandCompletedConsumer.java | 8 +- .../lastleg/client/DirectEventClientTest.java | 3 +- .../EventConsumptionExceptionHandlerTest.java | 22 +++++ .../LastlegOrderControllerTest.java | 2 +- ...InboundUnloadCompletedOutboxRelayTest.java | 2 +- 30 files changed, 384 insertions(+), 139 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandlerTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandler.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandlerTest.java diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 0bd37d101..98ad33565 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -14,6 +14,7 @@ | 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` | | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | +| 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | ## 修改与验证 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java new file mode 100644 index 000000000..e7b00596c --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java @@ -0,0 +1,7 @@ +package com.baiyue.common.event; + +/** Required business state has not arrived yet; retry the same event with backoff. */ +public class EventDependencyNotReadyException extends IllegalArgumentException { + public EventDependencyNotReadyException(String message) { super(message); } + public EventDependencyNotReadyException(String message, Throwable cause) { super(message, cause); } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java index 980c7b5b1..6d09aae10 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java @@ -75,7 +75,7 @@ public final class EventEnvelopeValidator { public static void requireValid(EventEnvelope envelope) { List errors = validate(envelope); if (!errors.isEmpty()) { - throw new IllegalArgumentException(String.join(";", errors)); + throw new EventProtocolException(String.join(";", errors)); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java new file mode 100644 index 000000000..7438b5507 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java @@ -0,0 +1,7 @@ +package com.baiyue.common.event; + +/** An immutable event violates the consumption protocol; retrying unchanged data cannot fix it. */ +public class EventProtocolException extends IllegalArgumentException { + public EventProtocolException(String message) { super(message); } + public EventProtocolException(String message, Throwable cause) { super(message, cause); } +} diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 019c951df..6e566731f 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -13,6 +13,7 @@ | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | +| 可靠事件 HTTP 客户端配置 | `EventDeliveryFeignConfiguration.java`;由业务事件 Feign 客户端显式选择,在最终构建阶段固定连接/读取超时、禁止重定向和客户端内部重试,不作为全局自动配置 | | RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java new file mode 100644 index 000000000..341bd0e36 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java @@ -0,0 +1,55 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import feign.FeignException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** Explicit failure classification and bounded, credential-free delivery diagnostics. */ +public class EventDeliveryFailure extends IllegalStateException { + private static final Set PERMANENT_CODES = Set.of("A0401", "A0403", "EVT_PROTOCOL"); + private final boolean permanent; + public EventDeliveryFailure(boolean permanent, String message) { + super(message); + this.permanent = permanent; + } + public static void requireSuccessful(ApiResponse response) { + requireSuccessful(response, Set.of()); + } + public static void requireSuccessful(ApiResponse response, Set additionalPermanentCodes) { + if (response == null) throw new EventDeliveryFailure(false, "Consumer returned an empty response"); + if (!response.successful()) { + throw new EventDeliveryFailure(response.code() != null && (PERMANENT_CODES.contains(response.code()) || additionalPermanentCodes.contains(response.code())), + "Consumer rejected event: code=" + response.code() + ", message=" + response.message() + + ", requestId=" + response.requestId() + ", traceId=" + response.traceId()); + } + if (response.data() == null) throw new EventDeliveryFailure(false, "Consumer ACK is missing"); + } + public static boolean isPermanent(Throwable failure) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable current = failure; current != null && seen.add(current); current = current.getCause()) { + if (current instanceof EventDeliveryFailure delivery && delivery.permanent) return true; + if (current instanceof FeignException http && (http.status() == 401 || http.status() == 403)) return true; + } + return false; + } + public static String diagnostic(Throwable failure, String internalToken) { + Throwable current = failure; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + while (current.getCause() != null && seen.add(current) + && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) + || "No fallback available.".equals(current.getMessage()))) current = current.getCause(); + String message; + if (current instanceof FeignException http) message = "HTTP " + http.status(); + else if (current instanceof JsonProcessingException) message = "Invalid event JSON"; + else message = current.getMessage() == null ? "unknown" : current.getMessage(); + String text = current.getClass().getSimpleName() + ": " + message; + if (internalToken != null && !internalToken.isBlank()) text = text.replace(internalToken, "***"); + text = text.replaceAll("(?i)Bearer\\s+[^\\s,;\\\"']+", "Bearer ***") + .replaceAll("(?i)((?:X-Internal-Service-Token|Authorization|access[_-]?token|refresh[_-]?token)\\s*[=:]\\s*)[^\\s,;]+", "$1***") + .replaceAll("[\\r\\n\\t]+", " "); + return text.length() > 500 ? text.substring(0, 500) : text; + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java new file mode 100644 index 000000000..c62009452 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java @@ -0,0 +1,55 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import feign.Request; +import feign.Response; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryFailureTest { + @Test void onlyExplicitProtocolOrAuthRejectionIsPermanent() { + for (String code : new String[]{"EVT_PROTOCOL", "A0401", "A0403"}) { + Throwable error = catchThrowable(() -> EventDeliveryFailure.requireSuccessful(response(code))); + assertThat(EventDeliveryFailure.isPermanent(error)).as(code).isTrue(); + } + for (String code : new String[]{"EVT_DEPENDENCY", "A0400", "A0409", "A0429", "S0503", "unknown", null}) { + Throwable error = catchThrowable(() -> EventDeliveryFailure.requireSuccessful(response(code))); + assertThat(error).isInstanceOf(EventDeliveryFailure.class); + assertThat(EventDeliveryFailure.isPermanent(error)).as(code).isFalse(); + } + assertThat(EventDeliveryFailure.isPermanent(new IllegalArgumentException("ACK mismatch"))).isFalse(); + } + @Test void httpAuthenticationFailureIsPermanentBut429And503Retry() { + for (int status : new int[]{401, 403, 429, 503}) { + var http = http(status); + assertThat(EventDeliveryFailure.isPermanent(new RuntimeException("wrapper", http))) + .isEqualTo(status == 401 || status == 403); + assertThat(EventDeliveryFailure.diagnostic(http, "secret")).contains("HTTP " + status) + .doesNotContain("remote-body", "secret"); + } + } + @Test void diagnosticsRemoveTokenHeadersSecretsAndLineBreaks() { + var failure = new IllegalStateException("secret-value\nAuthorization=Bearer abc.def.ghi; X-Internal-Service-Token=another-secret, access_token=third-secret"); + String value = EventDeliveryFailure.diagnostic(failure, "secret-value"); + assertThat(value).doesNotContain("secret-value", "abc.def.ghi", "another-secret", "third-secret", "\n"); + assertThat(EventDeliveryFailure.diagnostic(new RuntimeException("x".repeat(1000)), "")).hasSize(500); + } + @Test void malformedJsonDiagnosticNeverCopiesPersistedPayload() { + Throwable failure = catchThrowable(() -> new ObjectMapper().readTree("private-payload-value")); + assertThat(EventDeliveryFailure.diagnostic(failure, "")) + .contains("Invalid event JSON").doesNotContain("private-payload-value"); + } + private ApiResponse response(String code) { + return new ApiResponse<>(code, "consumer-message", null, "request-id", "trace-id", Instant.now()); + } + private FeignException http(int status) { + var request = Request.create(Request.HttpMethod.POST, "http://consumer/events?token=secret", Map.of(), new byte[0], StandardCharsets.UTF_8, null); + return FeignException.errorStatus("receive", Response.builder().request(request).status(status).reason("failure") + .headers(Map.of()).body("remote-body-secret", StandardCharsets.UTF_8).build()); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index ee71a1255..dd3f867d6 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -40,6 +40,9 @@ public class BusinessFeeSourceEventService { || envelope.targetRegion() != RegionCode.CN) { throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效"); } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); + } BusinessFeeSourceChangedV1 source = payload(envelope); validate(envelope, source); String sourceSystem = source.sourceSystem().trim().toUpperCase(); diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index a386fe13e..5677355b4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -112,6 +112,9 @@ public class WalletCommandEventService { || envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) { throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效"); } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); + } try { WalletCommandRequestedV1 value = objectMapper.treeToValue(envelope.payload(), WalletCommandRequestedV1.class); String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT); diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java index 2c787ddf8..ba02103bc 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -1,6 +1,8 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; import com.baiyue.common.core.retry.BoundedRetryPolicy; import com.baiyue.finance.client.WalletResultLastlegClient; import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,21 +52,25 @@ public class WalletResultOutboxRelay { "自动投递次数已耗尽(含租约超时)", eventId, worker); return; } + boolean requestStarted = false; try { EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); + requestStarted = true; var response = client.receive(internalToken, envelope); - if (response == null || !response.successful() || response.data() == null) - throw new IllegalStateException("Lastleg 未确认资金结果事件"); + EventDeliveryFailure.requireSuccessful(response); response.data().requireMatches(envelope); jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker); } catch (Exception exception) { + String error = EventDeliveryFailure.diagnostic(exception, internalToken); + boolean permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); int retries = failures + 1; - String status = policy.exhausted(retries) ? "DEAD" : "PENDING"; - LocalDateTime next = policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries)); + String status = permanent || policy.exhausted(retries) ? "DEAD" : "PENDING"; + LocalDateTime next = permanent || policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries)); jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - status, retries, next, safe(exception.getMessage()), eventId, worker); - log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage()); + status, retries, next, safe(error), eventId, worker); + log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, error); } } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java index 05129261d..fab833f4e 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -40,6 +40,18 @@ class WalletCommandEventServiceTest { when(transactions.getTransaction(any())).thenAnswer(invocation -> mock(TransactionStatus.class)); } + @Test + void rejectsTamperedEnvelopeBeforeOpeningWalletTransaction() { + EventEnvelope valid = envelope("FREEZE", 1L, BigDecimal.TEN); + EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), + valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), + valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), + valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); + assertThatThrownBy(() -> service.consume(tampered)).isInstanceOf(FinanceEventException.class) + .hasMessageContaining("摘要与实际载荷不一致"); + verifyNoInteractions(jdbc, wallet); + } + @Test void freezesWalletAndPublishesCompletedEvent() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50")); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java new file mode 100644 index 000000000..cda48dd7a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.time.Instant; + +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EventConsumptionExceptionHandler { + @ExceptionHandler({EventProtocolException.class, EventDependencyNotReadyException.class}) + public ApiResponse event(IllegalArgumentException failure) { + String code = failure instanceof EventDependencyNotReadyException ? "EVT_DEPENDENCY" : "EVT_PROTOCOL"; + return new ApiResponse<>(code, failure.getMessage(), null, MDC.get("requestId"), MDC.get("traceId"), Instant.now()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java index 34fb7eae5..40d0ef37f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -37,7 +37,7 @@ public class InternalOverseasContainerEventController { case EventTypes.OVERSEAS_CONTAINER_UNLOADED -> consumer.consume(event); case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> detainedConsumer.consume(event); case EventTypes.OVERSEAS_DELIVERY_COMPLETED -> deliveryCompletedConsumer.consume(event); - default -> throw new IllegalArgumentException("不支持的 Firstleg 业务事件类型"); + default -> throw new com.baiyue.common.event.EventProtocolException("不支持的 Firstleg 业务事件类型"); }; return ApiResponse.success(result); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index d7c99bc08..30a5964d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; import com.baiyue.common.core.retry.BoundedRetryPolicy; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.firstleg.client.LastlegEventClient; @@ -50,15 +52,19 @@ public class LoadingCompletedOutboxRelay { repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); continue; } + boolean requestStarted = false; try { EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); EventEnvelopeValidator.requireValid(event); + requestStarted = true; var response=client.receive(internalToken,event); requireAcknowledgement(event, response); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { - String error=safeError(exception); - if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); + boolean permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); + String error = EventDeliveryFailure.diagnostic(exception, internalToken); + if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), error); log.warn("头程业务事件投递失败: eventType={}, eventKey={}, reason={}", row.getEventType(), row.getEventKey(), error); @@ -68,33 +74,8 @@ public class LoadingCompletedOutboxRelay { private void requireAcknowledgement(EventEnvelope event, ApiResponse response) { - if(response==null) throw new IllegalStateException("Lastleg未返回响应"); - if(!response.successful()) { - throw new IllegalStateException("Lastleg拒绝头程业务事件: code=" + response.code() - + ", message=" + response.message() + ", requestId=" + response.requestId() - + ", traceId=" + response.traceId()); - } - if (response.data() == null) throw new IllegalStateException("Lastleg ACK 缺失"); + EventDeliveryFailure.requireSuccessful(response); response.data().requireMatches(event); } - private String safeError(Exception exception) { - Throwable diagnostic=diagnosticCause(exception); - String message=diagnostic.getMessage(); - String value=diagnostic.getClass().getSimpleName() - + ((message==null || message.isBlank())?"":": " + message); - if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); - value=value.replaceAll("[\\r\\n\\t]+"," "); - return value.length()>500?value.substring(0,500):value; - } - - private Throwable diagnosticCause(Throwable failure) { - Throwable current=failure; - while(current.getCause()!=null && current.getCause()!=current - && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) - || "No fallback available.".equals(current.getMessage()))) { - current=current.getCause(); - } - return current; - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 1eb91f8c9..e57b3fae5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -33,23 +35,23 @@ public class OverseasContainerEventConsumer { OverseasContainerUnloadedV1 payload = payload(event); if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank() || !payload.sourceContainerBookingId().equals(event.aggregateId())) { - throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致"); + throw new EventProtocolException("事件 aggregateId 与 sourceContainerBookingId 不一致"); } if (payload.containerNo() == null || payload.containerNo().isBlank()) { - throw new IllegalArgumentException("货柜号不能为空"); + throw new EventProtocolException("货柜号不能为空"); } if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) { - throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空"); + throw new EventProtocolException("卸柜完成时间和卸柜照片上传时间不能为空"); } if (payload.actualEmptyReportedAt() != null && payload.actualEmptyReportedAt().isBefore(payload.unloadingPhotoUploadedAt())) { - throw new IllegalArgumentException("实际报空时间不能早于卸柜照片上传时间"); + throw new EventProtocolException("实际报空时间不能早于卸柜照片上传时间"); } ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); if (existing != null) { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + throw new EventProtocolException("相同 eventId 对应的事件摘要冲突"); } if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); @@ -79,20 +81,20 @@ public class OverseasContainerEventConsumer { private void validateEnvelope(EventEnvelope event) { event.requireValid(); if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) { - throw new IllegalArgumentException("不支持的海外货柜事件"); + throw new EventProtocolException("不支持的海外货柜事件"); } if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN || !"CONTAINER_BOOKING".equals(event.aggregateType())) { - throw new IllegalArgumentException("海外货柜事件区域或聚合类型无效"); + throw new EventProtocolException("海外货柜事件区域或聚合类型无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { - throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + throw new EventProtocolException("payloadHash 与事件负载不一致"); } } private OverseasContainerUnloadedV1 payload(EventEnvelope event) { try { return objectMapper.treeToValue(event.payload(), OverseasContainerUnloadedV1.class); } - catch (Exception exception) { throw new IllegalArgumentException("海外货柜事件负载无效", exception); } + catch (Exception exception) { throw new EventProtocolException("海外货柜事件负载无效", exception); } } private ContainerEventInboxEntity inbox(EventEnvelope event) { @@ -110,12 +112,12 @@ public class OverseasContainerEventConsumer { try { bookingId = Long.parseLong(sourceContainerBookingId); } catch (NumberFormatException exception) { - throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception); + throw new EventProtocolException("国内货柜订舱 ID 无效", exception); } ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId); - if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用"); + if (value == null) throw new EventDependencyNotReadyException("头程货柜不存在或不可用"); if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) { - throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致"); + throw new EventProtocolException("事件货柜号与国内订舱记录不一致"); } return value; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java index 2834891dd..3693a8715 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -45,12 +47,12 @@ public class OverseasDeliveryCompletedConsumer { long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效"); long tenantId = positive(event.tenantId(), "客户 ID 无效"); if (!Objects.equals(event.aggregateId(), payload.sourceInboundId())) { - throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致"); + throw new EventProtocolException("事件 aggregateId 与来源入仓单 ID 不一致"); } ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); if (existing != null) { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + throw new EventProtocolException("相同 eventId 对应的事件摘要冲突"); } if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); @@ -65,7 +67,7 @@ public class OverseasDeliveryCompletedConsumer { } InboundOrderEntity inbound = required(inboundId, tenantId, payload); if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) { - throw new IllegalArgumentException("来源头程入仓单尚未装柜完成"); + throw new EventDependencyNotReadyException("来源头程入仓单尚未装柜完成"); } if ("LOADED".equals(inbound.getProcessStatus())) { LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC); @@ -102,10 +104,10 @@ public class OverseasDeliveryCompletedConsumer { if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) { - throw new IllegalArgumentException("不支持的海外派送完成事件"); + throw new EventProtocolException("不支持的海外派送完成事件"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { - throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + throw new EventProtocolException("payloadHash 与事件负载不一致"); } } @@ -114,22 +116,23 @@ public class OverseasDeliveryCompletedConsumer { OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class); if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo()) || !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo()) - || !StringUtils.hasText(value.inboundCode())) throw new IllegalArgumentException("字段不完整"); + || !StringUtils.hasText(value.inboundCode())) throw new EventProtocolException("字段不完整"); return value; } catch (Exception exception) { - throw new IllegalArgumentException("海外派送完成事件负载无效", exception); + throw new EventProtocolException("海外派送完成事件负载无效", exception); } } private InboundOrderEntity required(long inboundId, long tenantId, OverseasDeliveryCompletedV1 payload) { InboundOrderEntity inbound = inboundOrders.selectActiveByIdForUpdate(inboundId); - if (inbound == null || !Objects.equals(inbound.getTenantId(), tenantId) + if (inbound == null) throw new EventDependencyNotReadyException("来源头程入仓单不存在"); + if (!Objects.equals(inbound.getTenantId(), tenantId) || !Objects.equals(inbound.getInboundCode(), payload.inboundCode()) || !Objects.equals(String.valueOf(inbound.getOrderId()), payload.sourceOrderId()) || !Objects.equals(inbound.getOrderNo(), payload.sourceOrderNo()) || (StringUtils.hasText(payload.fbaWarehouseCode()) && StringUtils.hasText(inbound.getDestinationCode()) && !Objects.equals(inbound.getDestinationCode(), payload.fbaWarehouseCode()))) { - throw new IllegalArgumentException("事件与来源头程入仓单归属不一致"); + throw new EventProtocolException("事件与来源头程入仓单归属不一致"); } return inbound; } @@ -146,6 +149,6 @@ public class OverseasDeliveryCompletedConsumer { private static long positive(String value, String message) { try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } catch (RuntimeException ignored) { } - throw new IllegalArgumentException(message); + throw new EventProtocolException(message); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java index 8c9df62d2..261a74004 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -41,17 +43,17 @@ public class OverseasInboundOrderDetainedConsumer { long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效"); long tenantId = parsePositive(event.tenantId(), "客户 ID 无效"); if (!payload.sourceInboundId().equals(event.aggregateId())) { - throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致"); + throw new EventProtocolException("事件 aggregateId 与来源入仓单 ID 不一致"); } if (payload.changedAt() == null || payload.inboundCode() == null || payload.inboundCode().isBlank() || payload.reason() == null || payload.reason().isBlank()) { - throw new IllegalArgumentException("暂扣事件缺少入仓码、原因或变更时间"); + throw new EventProtocolException("暂扣事件缺少入仓码、原因或变更时间"); } ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); if (existing != null) { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + throw new EventProtocolException("相同 eventId 对应的事件摘要冲突"); } if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); @@ -87,23 +89,23 @@ public class OverseasInboundOrderDetainedConsumer { if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) || event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) { - throw new IllegalArgumentException("不支持的海外入仓单暂扣事件"); + throw new EventProtocolException("不支持的海外入仓单暂扣事件"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { - throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + throw new EventProtocolException("payloadHash 与事件负载不一致"); } } private OverseasInboundOrderDetainedChangedV1 payload(EventEnvelope event) { try { return objectMapper.treeToValue(event.payload(), OverseasInboundOrderDetainedChangedV1.class); } - catch (Exception exception) { throw new IllegalArgumentException("海外入仓单暂扣事件负载无效", exception); } + catch (Exception exception) { throw new EventProtocolException("海外入仓单暂扣事件负载无效", exception); } } private InboundOrderEntity requiredInbound(long inboundId, long tenantId, String inboundCode) { InboundOrderEntity value = inboundOrders.selectActiveByIdForUpdate(inboundId); - if (value == null) throw new IllegalArgumentException("来源头程入仓单不存在"); + if (value == null) throw new EventDependencyNotReadyException("来源头程入仓单不存在"); if (!Objects.equals(value.getTenantId(), tenantId) || !Objects.equals(value.getInboundCode(), inboundCode)) { - throw new IllegalArgumentException("事件与来源头程入仓单归属不一致"); + throw new EventProtocolException("事件与来源头程入仓单归属不一致"); } return value; } @@ -130,7 +132,7 @@ public class OverseasInboundOrderDetainedConsumer { private long parsePositive(String value, String message) { try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } catch (RuntimeException ignored) { } - throw new IllegalArgumentException(message); + throw new EventProtocolException(message); } private long parseOperatorId(String value) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandlerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandlerTest.java new file mode 100644 index 000000000..40cbb1d2d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandlerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class EventConsumptionExceptionHandlerTest { + @RestController static class Probe { + @GetMapping("/protocol") void protocol() { throw new EventProtocolException("invalid hash"); } + @GetMapping("/dependency") void dependency() { throw new EventDependencyNotReadyException("source not ready"); } + } + @Test void protocolAndDependencyHaveDistinctWireCodes() throws Exception { + var mvc = MockMvcBuilders.standaloneSetup(new Probe()).setControllerAdvice(new EventConsumptionExceptionHandler()).build(); + mvc.perform(get("/protocol")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("EVT_PROTOCOL")); + mvc.perform(get("/dependency")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("EVT_DEPENDENCY")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java index 4aa290029..4555c23df 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java @@ -5,12 +5,17 @@ import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventTypes; import lombok.RequiredArgsConstructor; +import com.baiyue.common.web.EventDeliveryFailure; +import java.util.Set; import org.springframework.stereotype.Component; /** Dispatches each supported business event directly to its owning service. */ @Component @RequiredArgsConstructor public class DirectEventClient { + private static final Set FINANCE_PERMANENT_CODES = Set.of( + "FIN-EVT-001", "FIN-EVT-002", "FIN-EVT-004", "FIN-EVT-007", "FIN-EVT-008", + "FIN-EVT-012", "FIN-EVT-015", "FIN-EVT-016", "FIN-EVT-401"); private final FirstlegEventClient firstleg; private final FinanceBusinessEventClient finance; @@ -20,9 +25,13 @@ public class DirectEventClient { case EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event); - case EventTypes.WALLET_COMMAND_REQUESTED -> finance.wallet(token, event); - case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> finance.fee(token, event); - default -> throw new IllegalArgumentException("Unsupported outbound event type: " + event.eventType()); + case EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event)); + case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event)); + default -> throw new EventDeliveryFailure(true, "Unsupported outbound event type: " + event.eventType()); }; } + private ApiResponse checkedFinance(ApiResponse response) { + EventDeliveryFailure.requireSuccessful(response, FINANCE_PERMANENT_CODES); + return response; + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandler.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandler.java new file mode 100644 index 000000000..15233ca4a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandler.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.time.Instant; + +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EventConsumptionExceptionHandler { + @ExceptionHandler({EventProtocolException.class, EventDependencyNotReadyException.class}) + public ApiResponse event(IllegalArgumentException failure) { + String code = failure instanceof EventDependencyNotReadyException ? "EVT_DEPENDENCY" : "EVT_PROTOCOL"; + return new ApiResponse<>(code, failure.getMessage(), null, MDC.get("requestId"), MDC.get("traceId"), Instant.now()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index e7312a6f9..0f2043c54 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -73,7 +73,7 @@ public class LastlegOrderController { } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { consumed = consumer.consume(event); } else { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); + throw new com.baiyue.common.event.EventProtocolException("Lastleg 不消费该事件类型: " + event.eventType()); } return ApiResponse.success(consumed); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java index 571f6c90d..8ab22ae04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.financefee.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; import com.baiyue.common.core.retry.BoundedRetryPolicy; import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; @@ -46,17 +48,21 @@ public class OverseasFeeOutboxRelay { repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"); continue; } + boolean requestStarted = false; try { EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + requestStarted = true; var response = client.receive(token, envelope); - if (response == null || !response.successful() || response.data() == null) - throw new IllegalStateException("Finance未确认费用事件"); + EventDeliveryFailure.requireSuccessful(response); response.data().requireMatches(envelope); repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); } catch (Exception exception) { + String error = EventDeliveryFailure.diagnostic(exception, token); + boolean permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); int retries = failures + 1; - if (policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, exception.getMessage()); - else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), exception.getMessage()); + if (permanent || policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, error); + else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), error); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index 2cbf52ffb..5749d7013 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; import com.baiyue.common.core.retry.BoundedRetryPolicy; import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; @@ -51,29 +53,29 @@ public class InboundUnloadCompletedOutboxRelay { repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); continue; } + boolean requestStarted = false; try { EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); EventEnvelopeValidator.requireValid(event); + requestStarted = true; var response = client.receive(internalToken, event); requireAcknowledgement(event, response); repository.published(row.getId(), workerId, LocalDateTime.now()); } catch (Exception exception) { - if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, exception.getMessage()); + String error = EventDeliveryFailure.diagnostic(exception, internalToken); + boolean permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); + if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), - exception.getMessage()); - log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage()); + error); + log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), error); } } } private void requireAcknowledgement(EventEnvelope event, com.baiyue.common.core.api.ApiResponse response) { - if (response == null) throw new IllegalStateException("消费服务返回空响应"); - if (!response.successful()) { - throw new IllegalStateException("消费服务拒绝海外事件: code=" + response.code() - + ", message=" + response.message() + ", requestId=" + response.requestId()); - } - if (response.data() == null) throw new IllegalStateException("消费服务成功响应缺少确认数据"); + EventDeliveryFailure.requireSuccessful(response); response.data().requireMatches(event); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index d3ca0bc7c..12f79d4c1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -55,23 +55,23 @@ public class LoadingCompletedConsumer { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3)) - throw new IllegalArgumentException("不支持的尾程交接事件"); + throw new EventProtocolException("不支持的尾程交接事件"); if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) - throw new IllegalArgumentException("尾程交接事件区域无效"); + throw new EventProtocolException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) - throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + throw new EventProtocolException("尾程交接事件聚合类型无效"); if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), - envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); + envelope.payloadHash())) throw new EventProtocolException("payloadHash 与尾程事件负载不一致"); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); String containerNo = ContainerNumbers.requireCanonical(payload.containerNo()); String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); - if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + if(payload.orders()==null||payload.orders().isEmpty()) throw new EventProtocolException("事件没有入仓单"); OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId()); if (envelope.eventVersion() >= 2) validateCartonSkuMappings(payload, envelope.payload()); freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { - if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); + if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new EventProtocolException("事件摘要冲突"); if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } @@ -84,7 +84,7 @@ public class LoadingCompletedConsumer { LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId()); if (raced == null || raced.getPayloadHash() == null || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("事件摘要冲突", exception); + throw new EventProtocolException("事件摘要冲突", exception); } if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); @@ -111,7 +111,7 @@ public class LoadingCompletedConsumer { if (envelope.eventVersion() < 3) return; JsonNode values = envelope.payload().path("loadingEvidence"); if (!values.isArray() || values.size() != 5) { - throw new IllegalArgumentException("V3 尾程交接事件必须包含五类国内装柜照片"); + throw new EventProtocolException("V3 尾程交接事件必须包含五类国内装柜照片"); } Set expected = Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"); Set actual = new HashSet<>(); @@ -121,7 +121,7 @@ public class LoadingCompletedConsumer { String fileName = value.path("fileName").asText("").trim(); if (!expected.contains(type) || !actual.add(type) || fileId <= 0 || fileName.isBlank() || fileName.length() > 255) { - throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片引用无效"); + throw new EventProtocolException("V3 尾程交接事件的国内装柜照片引用无效"); } ContainerLoadingEvidence existing = loadingEvidence.find(sourceContainerBookingId, type); if (existing != null) { @@ -143,7 +143,7 @@ public class LoadingCompletedConsumer { || !Objects.equals(raced.getFileName(), fileName)) throw duplicate; } } - if (!actual.equals(expected)) throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片类型不完整"); + if (!actual.equals(expected)) throw new EventProtocolException("V3 尾程交接事件的国内装柜照片类型不完整"); } private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { @@ -154,7 +154,7 @@ public class LoadingCompletedConsumer { // identity is validated separately from the original immutable payload. return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); } - catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } + catch(Exception exception) { throw new EventProtocolException("尾程事件负载无效",exception); } } private int countOrders(EventEnvelope envelope,String containerNo) { @@ -184,7 +184,7 @@ public class LoadingCompletedConsumer { ? envelope.payload().path("sourceContainerBookingId").asText(null) : payload.containerBookingId(); if (value == null || value.isBlank() || value.trim().length() > 128) { - throw new IllegalArgumentException("事件缺少稳定来源柜预订 ID sourceContainerBookingId"); + throw new EventProtocolException("事件缺少稳定来源柜预订 ID sourceContainerBookingId"); } return value.trim(); } @@ -194,12 +194,12 @@ public class LoadingCompletedConsumer { String customerId = requiredOwnerValue(orders.get(0).customerId(), "customerId"); String customerName = requiredOwnerValue(orders.get(0).customerName(), "customerName"); if (!trustedCustomerId.equals(customerId)) { - throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); + throw new EventProtocolException("Order customerId does not match the trusted event customer"); } for (FirstLegLoadingPlanCompletedV1.Order order : orders) { if (!customerId.equals(requiredOwnerValue(order.customerId(), "customerId")) || !customerName.equals(requiredOwnerValue(order.customerName(), "customerName"))) { - throw new IllegalArgumentException("同一装柜完成事件的货主快照不一致"); + throw new EventProtocolException("同一装柜完成事件的货主快照不一致"); } } return new OwnerSnapshot(customerId, customerName); @@ -207,11 +207,11 @@ public class LoadingCompletedConsumer { private String requiredOwnerValue(String value, String field) { if (value == null || value.isBlank()) { - throw new IllegalArgumentException("尾程事件缺少不可变货主快照 " + field); + throw new EventProtocolException("尾程事件缺少不可变货主快照 " + field); } String normalized = value.trim(); if (normalized.length() > 128) { - throw new IllegalArgumentException("尾程事件货主快照过长 " + field); + throw new EventProtocolException("尾程事件货主快照过长 " + field); } return normalized; } @@ -231,11 +231,11 @@ public class LoadingCompletedConsumer { } int manifestPackages = rawPallets.path(palletIndex).path("manifestPackageCount").asInt(-1); if (palletCartonMarks.size() != manifestPackages) { - throw new IllegalArgumentException("一件代发托盘物理箱数与清单箱数不一致: " + pallet.palletNo()); + throw new EventProtocolException("一件代发托盘物理箱数与清单箱数不一致: " + pallet.palletNo()); } for (String mark : palletCartonMarks) { if (!orderCartonMarks.add(mark)) { - throw new IllegalArgumentException("一件代发物理箱不能跨托盘重复: " + mark); + throw new EventProtocolException("一件代发物理箱不能跨托盘重复: " + mark); } } } @@ -257,7 +257,7 @@ public class LoadingCompletedConsumer { Set orderCartonMarks) { List marks = cartonMarks == null ? List.of() : cartonMarks; if (skuCode == null || skuCode.isBlank()) { - throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU"); + throw new EventProtocolException("V2 尾程交接事件缺少 SKU"); } if (marks.isEmpty()) return; requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks); @@ -266,15 +266,15 @@ public class LoadingCompletedConsumer { private void requireSkuCartonMapping(String skuCode, int loadedPackages, List cartonMarks, Set orderCartonMarks) { if (skuCode == null || skuCode.isBlank()) { - throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU"); + throw new EventProtocolException("V2 尾程交接事件的箱唛映射缺少 SKU"); } List marks = cartonMarks == null ? List.of() : cartonMarks; if (marks.size() != loadedPackages) { - throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量必须等于实装箱数: " + skuCode); + throw new EventProtocolException("V2 尾程交接事件的 SKU 箱唛数量必须等于实装箱数: " + skuCode); } for (String mark : marks) { if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) { - throw new IllegalArgumentException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode); + throw new EventProtocolException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode); } } } @@ -298,7 +298,7 @@ public class LoadingCompletedConsumer { List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets(source, rawSource) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { - throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); + throw new EventProtocolException("One-click dropship event has no pallet snapshot"); } order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); order.setDestinationType(destinationType); @@ -346,12 +346,12 @@ public class LoadingCompletedConsumer { for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { String palletNo = pallets.get(palletIndex).palletNo(); if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) { - throw new IllegalArgumentException("散货来源托盘码为空、重复或过长"); + throw new EventProtocolException("散货来源托盘码为空、重复或过长"); } sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId()); } if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) { - throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致"); + throw new EventProtocolException("散货来源托盘码数量与托盘数不一致"); } } for(var item:items) { @@ -379,7 +379,7 @@ public class LoadingCompletedConsumer { if ("ONE_CLICK_DROPSHIP".equals(orderType)) { long warehouseId; try { warehouseId = Long.parseLong(payload.warehouseId()); } - catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } + catch (Exception exception) { throw new EventProtocolException("One-click dropship warehouseId is invalid", exception); } dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, event.eventId() + ":DROPSHIP:" + source.sourceInboundId(), dropshipPallets)); } @@ -390,7 +390,7 @@ public class LoadingCompletedConsumer { try { return LocalDateTime.parse(value.trim()); } catch (java.time.format.DateTimeParseException exception) { - throw new IllegalArgumentException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception); + throw new EventProtocolException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception); } } @@ -447,7 +447,7 @@ public class LoadingCompletedConsumer { String detailId = sku.path("sourceInboundDetailId").asText(null); var item = itemsByDetail.get(detailId); if (item == null || !Objects.equals(item.skuCode(), sku.path("skuCode").asText(null))) { - throw new IllegalArgumentException("一件代发托盘来源明细缺失或与 SKU 不一致"); + throw new EventProtocolException("一件代发托盘来源明细缺失或与 SKU 不一致"); } List marks = marksByDetail.computeIfAbsent(detailId, ignored -> new java.util.ArrayList<>()); sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); @@ -455,7 +455,7 @@ public class LoadingCompletedConsumer { } for (var item : itemsByDetail.values()) { if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) { - throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致"); + throw new EventProtocolException("一件代发托盘箱唛与来源明细箱数不一致"); } } return marksByDetail; @@ -466,7 +466,7 @@ public class LoadingCompletedConsumer { Map> byDetail = new java.util.LinkedHashMap<>(); for (var item : source == null ? List.of() : source) { if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) { - throw new IllegalArgumentException("一件代发来源明细标识缺失"); + throw new EventProtocolException("一件代发来源明细标识缺失"); } byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item); } @@ -476,7 +476,7 @@ public class LoadingCompletedConsumer { if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode()) || !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId()) || !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) { - throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致"); + throw new EventProtocolException("同一来源明细的尾程事件分片快照不一致"); } List marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList(); result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(), @@ -512,7 +512,7 @@ public class LoadingCompletedConsumer { List marks = sourceMarks == null ? List.of() : sourceMarks; if (marks.isEmpty()) return; if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { - throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); + throw new EventProtocolException("装柜事件的箱唛池不可包含空值或重复箱唛"); } for (String mark : marks) { InboundPlanCarton carton = new InboundPlanCarton(); @@ -536,18 +536,18 @@ public class LoadingCompletedConsumer { boolean overseasWarehouseIdentity = hasText(payload.warehouseId()) && Objects.equals(payload.warehouseId(), order.destinationWarehouseId()); if (fbaEvidence && overseasWarehouseIdentity) { - throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId()); + throw new EventProtocolException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId()); } if (fbaEvidence) return "FBA"; if (overseasWarehouseIdentity) return "OVERSEAS"; - throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId()); + throw new EventProtocolException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId()); } private String normalizeDestinationType(String value) { if (!hasText(value)) return null; String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) { - throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS"); + throw new EventProtocolException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS"); } return normalized; } @@ -577,7 +577,7 @@ public class LoadingCompletedConsumer { List marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks()); Map quantities = new java.util.LinkedHashMap<>(left.cartonQuantities()); right.cartonQuantities().forEach((mark, quantity) -> { - if (quantities.putIfAbsent(mark, quantity) != null) throw new IllegalArgumentException("Duplicate carton quantity: " + mark); + if (quantities.putIfAbsent(mark, quantity) != null) throw new EventProtocolException("Duplicate carton quantity: " + mark); }); return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(), Math.addExact(left.packageCount(), right.packageCount()), @@ -620,25 +620,25 @@ public class LoadingCompletedConsumer { private int positiveManifest(JsonNode source, String field) { int value = source.path(field).asInt(0); - if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field); + if (value <= 0) throw new EventProtocolException("One-click dropship pallet is missing " + field); return value; } private String requiredManifestHash(JsonNode source) { String value = source.path("manifestHash").asText(null); if (value == null || !value.matches("(?i)[0-9a-f]{64}")) { - throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid"); + throw new EventProtocolException("One-click dropship pallet manifestHash is invalid"); } return value.toLowerCase(java.util.Locale.ROOT); } private Map cartonQuantities(JsonNode sku) { JsonNode node = sku.path("cartonQuantities"); - if (!node.isObject()) throw new IllegalArgumentException("One-click dropship cartonQuantities is required"); + if (!node.isObject()) throw new EventProtocolException("One-click dropship cartonQuantities is required"); Map result = new java.util.LinkedHashMap<>(); node.fields().forEachRemaining(entry -> { if (!entry.getValue().isIntegralNumber() || !entry.getValue().canConvertToLong() || entry.getValue().longValue() <= 0) { - throw new IllegalArgumentException("Invalid carton quantity: " + entry.getKey()); + throw new EventProtocolException("Invalid carton quantity: " + entry.getKey()); } result.put(entry.getKey(), entry.getValue().longValue()); }); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index e140c2cee..0e7b69132 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.shipping.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -36,7 +38,7 @@ public class ContainerShippingNodeConsumer { LastlegEventInbox existing = inboxes.selectById(envelope.eventId()); if (existing != null) { if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("船节点事件摘要冲突"); + throw new EventProtocolException("船节点事件摘要冲突"); } if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); @@ -46,7 +48,7 @@ public class ContainerShippingNodeConsumer { catch (DuplicateKeyException raced) { LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId()); if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("船节点事件摘要冲突", raced); + throw new EventProtocolException("船节点事件摘要冲突", raced); } if (!"PROCESSED".equals(concurrent.getStatus())) throw new IllegalStateException("事件尚未完成消费"); return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); @@ -55,8 +57,9 @@ public class ContainerShippingNodeConsumer { gates.initialize(payload.containerBookingId(), payload.oceanBookingId(), payload.containerNo(), 0, 1); ContainerShippingGate gate = gates.lock(payload.containerBookingId()); - if (gate == null || !payload.containerNo().equals(gate.getContainerNo())) { - throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); + if (gate == null) throw new EventDependencyNotReadyException("船节点柜级投影尚未就绪"); + if (!payload.containerNo().equals(gate.getContainerNo())) { + throw new EventProtocolException("船节点柜号快照与装柜交接不一致"); } boolean applied = apply(gate, envelope, payload); gates.updateById(gate); @@ -98,7 +101,7 @@ public class ContainerShippingNodeConsumer { envelope.requireValid(); if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { - throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致"); + throw new EventProtocolException("payloadHash 与船节点事件负载不一致"); } if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN @@ -106,19 +109,19 @@ public class ContainerShippingNodeConsumer { || !"CONTAINER_BOOKING".equals(envelope.aggregateType()) || envelope.handoverVersion() == null || envelope.handoverVersion() != envelope.aggregateVersion() + 1) { - throw new IllegalArgumentException("船节点交接信封无效"); + throw new EventProtocolException("船节点交接信封无效"); } } private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { if (!"0".equals(envelope.tenantId()) || !envelope.aggregateId().equals(payload.containerBookingId())) { - throw new IllegalArgumentException("船节点交接平台作用域或聚合标识不一致"); + throw new EventProtocolException("船节点交接平台作用域或聚合标识不一致"); } } private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) { try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); } - catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); } + catch (Exception exception) { throw new EventProtocolException("船节点交接负载无效", exception); } } private LastlegEventInbox inbox(EventEnvelope envelope) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java index 78a9ad765..ca8858b46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -14,11 +14,11 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; +import com.baiyue.common.event.EventProtocolException; import java.util.List; -import static org.springframework.http.HttpStatus.BAD_REQUEST; + @Service @RequiredArgsConstructor @@ -67,12 +67,12 @@ public class WalletCommandCompletedConsumer { || result.businessType() == null || result.businessType().isBlank() || result.businessNo() == null || result.businessNo().isBlank()) throw bad("资金结果事件字段无效"); return result; - } catch (ResponseStatusException exception) { throw exception; } + } catch (EventProtocolException exception) { throw exception; } catch (Exception exception) { throw bad("资金结果事件载荷无法解析"); } } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception exception) { throw bad("资金结果事件序列化失败"); } } - private static ResponseStatusException bad(String message) { return new ResponseStatusException(BAD_REQUEST, message); } + private static EventProtocolException bad(String message) { return new EventProtocolException(message); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java index 2235aa467..154faaaf0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -27,6 +27,7 @@ class DirectEventClientTest { @Test void feeIsSentOnlyToFinance() { var event = event(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + when(finance.fee("token", event)).thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED))); client.receive("token", event); verify(finance).fee("token", event); verifyNoMoreInteractions(finance); @@ -45,7 +46,7 @@ class DirectEventClientTest { @Test void unsupportedEventsHaveNoDefaultDestination() { assertThatThrownBy(() -> client.receive("token", event(EventTypes.WALLET_COMMAND_COMPLETED))) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unsupported outbound"); + .isInstanceOf(com.baiyue.common.web.EventDeliveryFailure.class).hasMessageContaining("Unsupported outbound"); verifyNoInteractions(firstleg, finance); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandlerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandlerTest.java new file mode 100644 index 000000000..1c559321f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandlerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class EventConsumptionExceptionHandlerTest { + @RestController static class Probe { + @GetMapping("/protocol") void protocol() { throw new EventProtocolException("invalid hash"); } + @GetMapping("/dependency") void dependency() { throw new EventDependencyNotReadyException("source not ready"); } + } + @Test void protocolAndDependencyHaveDistinctWireCodes() throws Exception { + var mvc = MockMvcBuilders.standaloneSetup(new Probe()).setControllerAdvice(new EventConsumptionExceptionHandler()).build(); + mvc.perform(get("/protocol")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("EVT_PROTOCOL")); + mvc.perform(get("/dependency")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("EVT_DEPENDENCY")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index 9f311cfbb..d9d71ed41 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -155,7 +155,7 @@ class LastlegOrderControllerTest { when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); - assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event)); + assertThrows(com.baiyue.common.event.EventProtocolException.class, () -> controller.receive("internal-token", event)); verify(loading, never()).consume(event); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 49d3ee511..d542c183b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -75,7 +75,7 @@ class InboundUnloadCompletedOutboxRelayTest { relay.relay(); - verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("consumer unavailable")); + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), contains("consumer unavailable")); verify(repository, never()).published(anyLong(), anyString(), any()); } -- 2.54.0 From c2927ab9a6b74f522f0223af3581a2550d1e57d2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:47:37 +0800 Subject: [PATCH 011/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 2 - AGENTS.md | 24 +- Jenkinsfile | 203 +------- Jenkinsfile.service | 152 +----- README.md | 21 +- audit-service/AGENTS.md | 2 +- .../src/main/resources/application-dev.yml | 6 - .../src/main/resources/application-test.yml | 6 - .../src/main/resources/application.yml | 20 +- auth/AGENTS.md | 6 +- auth/pom.xml | 1 + .../AuthorizationFeignConfiguration.java | 14 + .../auth/client/AuthorizationSnapshot.java | 8 + .../auth/client/IamAuthorizationClient.java | 2 +- .../controller/InternalAccountController.java | 22 +- .../dto/OverseasSessionValidationDtos.java | 29 -- .../repository/AuthAccountRepository.java | 10 +- .../service/AccountManagementService.java | 2 +- .../com/baiyue/auth/service/AuthService.java | 15 +- .../OverseasSessionValidationService.java | 38 -- .../auth/service/SessionStateService.java | 63 +++ .../auth/service/TokenSessionStore.java | 63 +-- auth/src/main/resources/application-dev.yml | 10 - auth/src/main/resources/application-test.yml | 15 - auth/src/main/resources/application.yml | 24 +- .../main/resources/openapi/auth-internal.yaml | 39 ++ ...ssionValidationControllerContractTest.java | 28 -- .../auth/service/AuthServiceSmallJwtTest.java | 8 +- .../OverseasSessionValidationServiceTest.java | 77 ---- .../service/SessionStateConcurrencyTest.java | 281 +++++++++++ .../auth/service/TokenSessionStoreTest.java | 4 +- common-core/AGENTS.md | 1 + common-core/pom.xml | 2 + .../core/outbox/EventDeliveryJournal.java | 59 +++ .../core/outbox/EventDeliveryProducer.java | 4 + .../outbox/EventOperationsTokenGuard.java | 20 + .../common/core/outbox/OutboxOperations.java | 129 ++++++ .../common/core/EventDeliveryJournalTest.java | 83 ++++ .../core/EventOperationsTokenGuardTest.java | 23 + .../common/core/OutboxOperationsTest.java | 88 ++++ common-event-contract/AGENTS.md | 4 +- .../baiyue/common/event/EventEnvelope.java | 6 +- .../common/event/EventEnvelopeValidator.java | 6 - .../com/baiyue/common/event/RegionCode.java | 13 - .../baiyue/common/event/DeliveryAckTest.java | 3 +- .../common/event/EventEnvelopeTest.java | 8 +- common-security/AGENTS.md | 5 +- .../BaiyueSecurityAutoConfiguration.java | 7 +- .../security/HttpSessionStateVerifier.java | 44 ++ .../RedisAccessTokenSessionValidator.java | 8 +- .../common/security/SessionStateVerifier.java | 7 + .../HttpSessionStateVerifierTest.java | 45 ++ .../security/JwtAuthenticationFilterTest.java | 2 +- .../RedisAccessTokenSessionValidatorTest.java | 10 +- .../security/UnifiedSessionModeTest.java | 2 +- common-web/pom.xml | 2 + .../web/EventDeliveryFeignConfiguration.java | 31 ++ .../common/web/EventDeliveryMetrics.java | 78 ++++ .../common/web/EventDeliveryObservation.java | 29 ++ ...tDeliveryObservationAutoConfiguration.java | 25 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../EventDeliveryFeignConfigurationTest.java | 25 + .../common/web/EventDeliveryMetricsTest.java | 71 +++ ...iveryObservationAutoConfigurationTest.java | 53 +++ .../web/EventDeliveryObservationTest.java | 22 + config/nacos/cn/README.md | 5 - config/nacos/cn/auth-service.yml | 17 - config/nacos/cn/event-bridge-service.yml | 15 - config/nacos/cn/finance-service.yml | 9 - config/nacos/local/README.md | 37 -- config/nacos/local/audit-service.yml | 5 - config/nacos/local/auth-service.yml | 15 - config/nacos/local/baiyue-common.yml | 73 --- config/nacos/local/event-bridge-service.yml | 11 - config/nacos/local/file-service.yml | 123 ----- config/nacos/local/finance-service.yml | 9 - config/nacos/local/firstleg-service.yml | 69 --- config/nacos/local/gateway-overseas.yml | 16 - config/nacos/local/gateway.yml | 11 - config/nacos/local/iam-service.yml | 16 - config/nacos/local/integration-service.yml | 53 --- config/nacos/local/lastleg-service.yml | 29 -- config/nacos/local/order-service.yml | 11 - config/nacos/local/print-service.yml | 11 - .../local/warehouse-overseas-service.yml | 19 - config/nacos/local/warehouse-service.yml | 5 - config/nacos/overseas/README.md | 19 - config/nacos/overseas/audit-service.yml | 6 - config/nacos/overseas/baiyue-common.yml | 67 --- .../nacos/overseas/event-bridge-service.yml | 21 - config/nacos/overseas/file-service.yml | 13 - config/nacos/overseas/gateway-overseas.yml | 9 - config/nacos/overseas/integration-service.yml | 12 - config/nacos/overseas/print-service.yml | 6 - config/nacos/test/README.md | 9 + config/nacos/{cn => test}/audit-service.yml | 0 config/nacos/test/auth-service.yml | 12 + config/nacos/{cn => test}/baiyue-common.yml | 18 +- config/nacos/{cn => test}/file-service.yml | 0 config/nacos/test/finance-service.yml | 18 + .../nacos/{cn => test}/firstleg-service.yml | 12 +- config/nacos/{cn => test}/gateway.yml | 0 config/nacos/{cn => test}/iam-service.yml | 2 - .../{cn => test}/integration-service.yml | 0 .../{overseas => test}/lastleg-service.yml | 14 +- config/nacos/{cn => test}/order-service.yml | 0 config/nacos/{cn => test}/print-service.yml | 0 .../warehouse-overseas-service.yml | 0 .../nacos/{cn => test}/warehouse-service.yml | 0 deploy/.env.overseas-auth.example | 12 - deploy/TEST单机部署执行清单.md | 42 ++ deploy/docker-compose.audit.yml | 22 +- deploy/docker-compose.overseas.yml | 116 ----- deploy/docker-compose.rocketmq.yml | 2 +- deploy/docker-compose.yml | 168 ++++--- deploy/docker/Dockerfile | 2 +- deploy/env/.env.infra-test.example | 2 +- deploy/env/.env.test.example | 54 +++ deploy/scripts/deploy-blue-green-service.sh | 411 ----------------- deploy/scripts/deploy-built-service.sh | 436 ++---------------- deploy/scripts/deploy-development-service.sh | 160 ------- deploy/scripts/deploy-services.sh | 226 +-------- deploy/scripts/initialize-test-databases.sh | 56 +-- deploy/service-map.yml | 12 - deploy/sql/README.md | 16 +- deploy/sql/migration-scopes.json | 10 +- .../V1__event_delivery_retry_audit.sql | 22 + .../V2__event_delivery_attempt.sql | 23 + .../V5__event_delivery_retry_audit.sql | 22 + .../V6__event_delivery_attempt.sql | 23 + .../V6__event_delivery_retry_audit.sql | 22 + .../V7__event_delivery_attempt.sql | 23 + deploy/sql/头程数据库统一增量-MySQL8.sql | 177 +++---- deploy/sql/海外区域统一增量-MySQL8.sql | 155 ++----- docs/头程项目目录结构与服务说明.md | 34 +- file/src/main/resources/application-dev.yml | 6 - file/src/main/resources/application-test.yml | 6 - file/src/main/resources/application.yml | 19 +- finance/AGENTS.md | 4 +- .../controller/EventOperationsController.java | 56 +++ .../BusinessFeeSourceEventService.java | 6 +- .../service/WalletCommandEventService.java | 8 +- .../service/WalletResultOutboxRelay.java | 65 ++- .../src/main/resources/application-dev.yml | 11 - finance/src/main/resources/application.yml | 21 +- finance/src/main/resources/db/finance.sql | 35 ++ .../BusinessFeeSourceEventServiceTest.java | 7 +- .../WalletCommandEventServiceTest.java | 8 +- .../finance/WalletCommandTransactionTest.java | 2 +- .../finance/WalletResultOutboxLeaseTest.java | 22 +- .../baiyue/finance/WalletResultRetryTest.java | 35 +- .../EventOperationsControllerTest.java | 22 + firstleg/AGENTS.md | 2 + .../controller/EventOperationsController.java | 56 +++ .../CarrierScheduleNodeOutboxService.java | 3 +- .../LoadingCompletedOutboxRepository.java | 37 +- .../service/LoadingCompletedOutboxRelay.java | 86 ++-- .../LoadingCompletedOutboxService.java | 3 +- .../OverseasContainerEventConsumer.java | 6 +- .../OverseasDeliveryCompletedConsumer.java | 2 - .../OverseasInboundOrderDetainedConsumer.java | 4 +- .../src/main/resources/application-dev.yml | 11 - .../src/main/resources/application-test.yml | 6 - firstleg/src/main/resources/application.yml | 20 +- .../resources/db/phase2-inbound-schema.sql | 35 ++ .../EventOperationsControllerTest.java | 22 + .../LoadingCompletedOutboxRepositoryTest.java | 4 +- .../repository/OutboxLeaseDatabaseTest.java | 57 +++ .../LoadingCompletedOutboxRelayTest.java | 48 +- .../OverseasContainerEventConsumerTest.java | 5 +- ...OverseasDeliveryCompletedConsumerTest.java | 2 +- ...rseasInboundOrderDetainedConsumerTest.java | 4 +- gateway/AGENTS.md | 2 +- .../config/RedisTokenSessionWebFilter.java | 23 + gateway/src/main/resources/application-cn.yml | 10 - gateway/src/main/resources/application.yml | 29 +- .../RedisTokenSessionWebFilterTest.java | 58 +++ iam/AGENTS.md | 6 +- iam/pom.xml | 1 - .../iam/client/SecurityEventBridgeClient.java | 18 - .../com/baiyue/iam/dto/AuthorizationDtos.java | 8 + .../iam/dto/AuthorizationSnapshotRow.java | 14 + .../iam/mapper/AuthorizationQueryMapper.java | 122 +---- .../iam/mapper/IamMembershipRoleMapper.java | 3 +- .../baiyue/iam/repository/RbacRepository.java | 2 +- .../service/AuthorizationSnapshotService.java | 19 +- .../com/baiyue/iam/service/MemberService.java | 10 +- .../service/MerchantOnboardingService.java | 7 +- .../service/PlatformInternalUserService.java | 20 +- .../PlatformUserManagementService.java | 13 +- .../com/baiyue/iam/service/RbacService.java | 12 +- .../iam/service/SecurityEventOutboxRelay.java | 91 ---- .../service/SecurityEventOutboxService.java | 83 ---- .../iam/service/SecurityVersionService.java | 24 + ...enantAdministratorProvisioningService.java | 14 +- .../com/baiyue/iam/service/TenantService.java | 8 +- iam/src/main/resources/application-dev.yml | 6 - iam/src/main/resources/application-test.yml | 15 - iam/src/main/resources/application.yml | 20 +- .../resources/db/security-event-outbox.sql | 35 -- .../main/resources/db/security-version.sql | 16 + iam/src/main/resources/openapi/iam-v1.yaml | 46 ++ ...SecurityEventBridgeClientContractTest.java | 35 -- .../mapper/AuthorizationIdentitySqlTest.java | 24 +- .../SecurityEventSqlContractTest.java | 42 -- .../SecurityVersionSqlContractTest.java | 26 ++ .../AuthorizationSnapshotServiceTest.java | 82 ++-- .../AuthorizationSnapshotTransactionTest.java | 90 ++++ .../baiyue/iam/service/MemberServiceTest.java | 8 +- .../MerchantOnboardingServiceTest.java | 8 +- .../PlatformInternalUserServiceTest.java | 14 +- .../PlatformUserManagementServiceTest.java | 17 +- .../baiyue/iam/service/RbacServiceTest.java | 5 +- .../service/SecurityEventOutboxRelayTest.java | 93 ---- .../SecurityEventOutboxServiceTest.java | 103 ----- .../SecurityVersionTransactionTest.java | 46 ++ ...tAdministratorProvisioningServiceTest.java | 15 +- .../iam/service/TenantCustomerExportTest.java | 2 +- .../baiyue/iam/service/TenantServiceTest.java | 10 +- integration-service/AGENTS.md | 2 +- .../src/main/resources/application.yml | 16 +- lastleg-service/AGENTS.md | 12 +- lastleg-service/docs/business-index.md | 2 +- .../controller/EventOperationsController.java | 56 +++ .../DeliveryCompletedOutboxService.java | 5 +- .../OverseasFeeOutboxRepository.java | 36 +- .../service/OverseasFeeOutboxRelay.java | 68 ++- .../service/OverseasFeeOutboxService.java | 3 +- .../InboundUnloadCompletedOutboxRelay.java | 79 ++-- .../InboundUnloadCompletedOutboxService.java | 3 +- .../UnloadCompletedOutboxRepository.java | 38 +- .../controller/LastlegSecurityController.java | 53 --- .../security/dto/LastlegSecurityDtos.java | 39 -- .../LastlegSecurityProjectionRedisMirror.java | 36 -- .../LastlegSecurityProjectionService.java | 208 --------- .../LastlegSecurityValidationService.java | 82 ---- .../SecurityProjectionMonotonicity.java | 19 - .../InboundOrderDetainedOutboxService.java | 4 +- .../service/LoadingCompletedConsumer.java | 10 +- .../ContainerShippingNodeConsumer.java | 4 +- .../WalletCommandCompletedConsumer.java | 6 +- .../wallet/WalletCommandOutboxService.java | 3 +- .../src/main/resources/application.yml | 37 +- .../src/main/resources/db/lastleg.sql | 210 ++------- .../lastleg/client/DirectEventClientTest.java | 3 +- .../EventOperationsControllerTest.java | 22 + .../OverseasFeeOutboxRetryTest.java | 14 +- ...InboundUnloadCompletedOutboxRelayTest.java | 43 +- ...boundUnloadCompletedOutboxServiceTest.java | 1 - .../repository/OutboxLeaseDatabaseTest.java | 112 +++-- .../UnloadCompletedOutboxRepositoryTest.java | 8 +- .../security/LastlegSecurityContractTest.java | 103 ----- .../SecurityProjectionArchitectureTest.java | 49 -- ...astlegSecurityProjectionTransportTest.java | 56 --- .../LastlegSecurityValidationServiceTest.java | 69 --- .../SecurityProjectionMonotonicityTest.java | 28 -- .../service/LoadingCompletedConsumerTest.java | 44 +- .../ContainerShippingNodeConsumerTest.java | 5 +- order/src/main/resources/application-dev.yml | 6 - order/src/main/resources/application-test.yml | 17 - order/src/main/resources/application.yml | 19 +- pom.xml | 2 - print/AGENTS.md | 2 +- print/src/main/resources/application.yml | 17 +- scripts/README.md | 12 +- .../initialization-baseline-contract.test.mjs | 32 +- scripts/agents-index-contract.ps1 | 75 ++- scripts/audit-deployment-contract.test.mjs | 4 +- .../blue-green-deployment-contract.test.mjs | 104 ----- .../blue-green-deployment-runtime.test.mjs | 165 ------- scripts/container-logging-contract.test.mjs | 7 +- ...ema-alignment-migrations-contract.test.mjs | 13 +- .../deploy-built-service-rollback.test.mjs | 37 +- scripts/event-outbox-operations.mjs | 50 ++ scripts/event-outbox-operations.test.mjs | 23 + scripts/finance-deployment-contract.test.mjs | 8 +- scripts/java-topology-contract.test.mjs | 31 +- scripts/overseas-deployment-contract.test.mjs | 245 ++-------- scripts/publish-local-nacos-config.ps1 | 49 -- scripts/publish-nacos-config.ps1 | 79 +--- scripts/synchronous-dependency-baseline.mjs | 36 +- .../synchronous-dependency-contract.test.mjs | 53 +-- ...est-environment-pipeline-contract.test.mjs | 158 ++----- scripts/use-nacos-environment.ps1 | 85 +--- warehouse-overseas-service/AGENTS.md | 2 +- .../src/main/resources/application.yml | 34 +- warehouse/AGENTS.md | 1 + .../src/main/resources/application-dev.yml | 6 - .../src/main/resources/application-test.yml | 15 - warehouse/src/main/resources/application.yml | 19 +- .../main/resources/openapi/warehouse-v1.yaml | 55 ++- 291 files changed, 4008 insertions(+), 6298 deletions(-) delete mode 100644 audit-service/src/main/resources/application-dev.yml delete mode 100644 audit-service/src/main/resources/application-test.yml create mode 100644 auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java delete mode 100644 auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java delete mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java create mode 100644 auth/src/main/java/com/baiyue/auth/service/SessionStateService.java delete mode 100644 auth/src/main/resources/application-dev.yml delete mode 100644 auth/src/main/resources/application-test.yml create mode 100644 auth/src/main/resources/openapi/auth-internal.yaml delete mode 100644 auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java delete mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java delete mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java create mode 100644 common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java create mode 100644 common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java create mode 100644 common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java delete mode 100644 config/nacos/cn/README.md delete mode 100644 config/nacos/cn/auth-service.yml delete mode 100644 config/nacos/cn/event-bridge-service.yml delete mode 100644 config/nacos/cn/finance-service.yml delete mode 100644 config/nacos/local/README.md delete mode 100644 config/nacos/local/audit-service.yml delete mode 100644 config/nacos/local/auth-service.yml delete mode 100644 config/nacos/local/baiyue-common.yml delete mode 100644 config/nacos/local/event-bridge-service.yml delete mode 100644 config/nacos/local/file-service.yml delete mode 100644 config/nacos/local/finance-service.yml delete mode 100644 config/nacos/local/firstleg-service.yml delete mode 100644 config/nacos/local/gateway-overseas.yml delete mode 100644 config/nacos/local/gateway.yml delete mode 100644 config/nacos/local/iam-service.yml delete mode 100644 config/nacos/local/integration-service.yml delete mode 100644 config/nacos/local/lastleg-service.yml delete mode 100644 config/nacos/local/order-service.yml delete mode 100644 config/nacos/local/print-service.yml delete mode 100644 config/nacos/local/warehouse-overseas-service.yml delete mode 100644 config/nacos/local/warehouse-service.yml delete mode 100644 config/nacos/overseas/README.md delete mode 100644 config/nacos/overseas/audit-service.yml delete mode 100644 config/nacos/overseas/baiyue-common.yml delete mode 100644 config/nacos/overseas/event-bridge-service.yml delete mode 100644 config/nacos/overseas/file-service.yml delete mode 100644 config/nacos/overseas/gateway-overseas.yml delete mode 100644 config/nacos/overseas/integration-service.yml delete mode 100644 config/nacos/overseas/print-service.yml create mode 100644 config/nacos/test/README.md rename config/nacos/{cn => test}/audit-service.yml (100%) create mode 100644 config/nacos/test/auth-service.yml rename config/nacos/{cn => test}/baiyue-common.yml (81%) rename config/nacos/{cn => test}/file-service.yml (100%) create mode 100644 config/nacos/test/finance-service.yml rename config/nacos/{cn => test}/firstleg-service.yml (87%) rename config/nacos/{cn => test}/gateway.yml (100%) rename config/nacos/{cn => test}/iam-service.yml (84%) rename config/nacos/{cn => test}/integration-service.yml (100%) rename config/nacos/{overseas => test}/lastleg-service.yml (77%) rename config/nacos/{cn => test}/order-service.yml (100%) rename config/nacos/{cn => test}/print-service.yml (100%) rename config/nacos/{overseas => test}/warehouse-overseas-service.yml (100%) rename config/nacos/{cn => test}/warehouse-service.yml (100%) delete mode 100644 deploy/.env.overseas-auth.example create mode 100644 deploy/TEST单机部署执行清单.md delete mode 100644 deploy/docker-compose.overseas.yml create mode 100644 deploy/env/.env.test.example delete mode 100644 deploy/scripts/deploy-blue-green-service.sh delete mode 100644 deploy/scripts/deploy-development-service.sh create mode 100644 deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql create mode 100644 deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql delete mode 100644 file/src/main/resources/application-dev.yml delete mode 100644 file/src/main/resources/application-test.yml create mode 100644 finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java delete mode 100644 finance/src/main/resources/application-dev.yml create mode 100644 finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java delete mode 100644 firstleg/src/main/resources/application-dev.yml delete mode 100644 firstleg/src/main/resources/application-test.yml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java delete mode 100644 gateway/src/main/resources/application-cn.yml create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java delete mode 100644 iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java create mode 100644 iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java delete mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java delete mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java delete mode 100644 iam/src/main/resources/application-dev.yml delete mode 100644 iam/src/main/resources/application-test.yml delete mode 100644 iam/src/main/resources/db/security-event-outbox.sql create mode 100644 iam/src/main/resources/db/security-version.sql delete mode 100644 iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java delete mode 100644 iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java delete mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java delete mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java delete mode 100644 order/src/main/resources/application-dev.yml delete mode 100644 order/src/main/resources/application-test.yml delete mode 100644 scripts/blue-green-deployment-contract.test.mjs delete mode 100644 scripts/blue-green-deployment-runtime.test.mjs create mode 100644 scripts/event-outbox-operations.mjs create mode 100644 scripts/event-outbox-operations.test.mjs delete mode 100644 scripts/publish-local-nacos-config.ps1 delete mode 100644 warehouse/src/main/resources/application-dev.yml delete mode 100644 warehouse/src/main/resources/application-test.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3fd5c31fe..4742c078c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -89,7 +89,6 @@ OpenAPI有效性: expire_in: 7 days paths: - gateway/target/*.jar - - gateway-overseas/target/*.jar - auth/target/*.jar - iam/target/*.jar - order/target/*.jar @@ -102,4 +101,3 @@ OpenAPI有效性: - print/target/*.jar - audit-service/target/*.jar - integration-service/target/*.jar - - event-bridge-service/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index df742e218..78460ce9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,18 +2,20 @@ > 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。 +根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。 + ## 适用范围与定位 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 -- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 +- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;统一 Token、API 和可靠直接投递公共契约见 `../docs/统一认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 ## 不可突破的产品与数据边界 - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。 -- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 +- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,所有端公网入口归 `gateway`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 ## 服务路由(按关键词定位) @@ -51,9 +53,9 @@ | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | | 目的港码头拖车与还空预警 | `firstleg/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | -| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | -| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` | @@ -62,8 +64,8 @@ | 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | -| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | -| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | +| 统一 API 路由、Token、Audience/JWKS | `gateway/`、`auth/`、`common-security/` | +| 统一会话与账号/客户权限撤销 | `auth/`、`iam/`、`common-security/` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | | 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | @@ -76,7 +78,7 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | -| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | +| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | ## 公共模块快速索引 @@ -97,8 +99,8 @@ ## 国内头程与海外仓协同 -- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。原双 Token 与 Lastleg 安全投影在替代撤销链验证后移除。 -- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;现有 Bridge 尚未退役,不得先停服务后补替代链。 +- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。 +- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。 - `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 ## 契约、数据库与验证入口 @@ -113,7 +115,7 @@ - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 -- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 +- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 @@ -139,7 +141,7 @@ - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md` - 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取 - 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取 -- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` +- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 diff --git a/Jenkinsfile b/Jenkinsfile index 861c71258..047ec59ad 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,11 +1,10 @@ def serviceModules = [ - 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service' ] def serviceCheckboxes = [ 'gateway': 'SERVICE_GATEWAY', - 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', 'auth': 'SERVICE_AUTH', 'iam': 'SERVICE_IAM', 'firstleg': 'SERVICE_FIRSTLEG', @@ -17,20 +16,19 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] def fullRebuildPaths = [ 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', - 'deploy/docker-compose.yml', 'deploy/service-map.yml' + 'deploy/docker-compose.yml', 'deploy/service-map.yml', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile' ] def commonPathServices = [ 'common-web/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'integration-service', 'lastleg-service', 'warehouse-overseas-service', - 'event-bridge-service', 'audit-service' + 'audit-service' ], 'common-security/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', @@ -39,20 +37,16 @@ def commonPathServices = [ 'common-openapi/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'integration-service', 'lastleg-service', 'warehouse-overseas-service', - 'event-bridge-service', 'audit-service' + 'audit-service' ], 'common-audit/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'warehouse-overseas-service', 'audit-service' ], 'common-event-contract/': [ - 'firstleg', 'iam', 'lastleg-service', 'event-bridge-service' - ], - 'deploy/docker-compose.overseas.yml': [ - 'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service' + 'firstleg', 'finance', 'lastleg-service' ], 'deploy/docker-compose.audit.yml': ['audit-service'], - 'deploy/docker-compose.rocketmq.yml': ['event-bridge-service'] ] def databaseMigrationPaths = ['deploy/sql/'] @@ -63,45 +57,6 @@ def downstreamResults = [:] def downstreamBuildUrls = [:] def downstreamBuildNumbers = [:] -def sendProductionWeComNotification(String title, String status, String color, String detail) { - node('java-builder') { - withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { - String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '未解析' - String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—' - String content = """## ${title} -> 应用:后端服务 -> Job:${env.JOB_NAME} -> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) -> 提交:${sourceVersion} -> 状态:${status} -${detail} -> 耗时:${duration} -> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} -""" - String payloadFile = 'wecom-production-notification.json' - writeFile( - file: payloadFile, - text: groovy.json.JsonOutput.toJson([ - msgtype: 'markdown', - markdown: [content: content] - ]), - encoding: 'UTF-8' - ) - sh ''' - set -eu - payload_file='wecom-production-notification.json' - trap 'rm -f "$payload_file"' EXIT HUP INT TERM - response="$(curl --fail --silent --show-error \ - -H 'Content-Type: application/json' \ - --data-binary @"$payload_file" \ - "$WECOM_WEBHOOK")" - printf '%s\n' "$response" - printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' - ''' - } - } -} - def parseAutoBuildState(String description, List allowedServices) { if (!description?.startsWith('AUTO ')) return null int markerIndex = description.indexOf(' | ') @@ -154,12 +109,8 @@ def environmentChoicesForJob(String jobBaseName) { def fixedEnvironmentForJob(String jobBaseName) { switch (jobBaseName) { - case 'backend-java-dev': - return 'development' case 'backend-java': return 'test' - case 'backend-java-prod': - return 'production' default: return null } @@ -167,12 +118,8 @@ def fixedEnvironmentForJob(String jobBaseName) { def fixedDownstreamJobPrefixForJob(String jobBaseName) { switch (jobBaseName) { - case 'backend-java-dev': - return 'baiyue-firstleg-dev-' case 'backend-java': return 'baiyue-firstleg-' - case 'backend-java-prod': - return 'baiyue-firstleg-prod-' default: return null } @@ -195,9 +142,8 @@ pipeline { parameters { choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)') - booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') - booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') + booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') @@ -207,14 +153,12 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') - booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } @@ -230,7 +174,7 @@ pipeline { } if (params.ENVIRONMENT != resolvedEnvironment || params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { - echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}" + error('当前流水线仅允许 TEST 与 baiyue-firstleg- 下游前缀') } env.RESOLVED_ENVIRONMENT = resolvedEnvironment echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" @@ -239,16 +183,7 @@ pipeline { env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() - if (env.RESOLVED_ENVIRONMENT == 'production') { - sh ''' - set -eu - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { - echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2 - exit 1 - } - ''' - } + String requestedRepository = params.IMAGE_REPOSITORY?.trim() if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { @@ -262,9 +197,7 @@ pipeline { env.RESOLVED_JOB_PREFIX = resolvedJobPrefix env.DATABASE_CHANGE_DETECTED = 'false' - if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { - error('生产环境禁止跳过测试') - } + List selected = [] if (params.BUILD_MODE == 'ALL') { @@ -326,15 +259,7 @@ pipeline { } selected = selected.unique() - if (params.DEPLOY && selected.contains('event-bridge-service')) { - String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() - if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { - error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') - } - if (bridgeRegion == 'SELECT') { - echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' - } - } + env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { @@ -426,14 +351,6 @@ pipeline { String service = selectedService branches[service] = { String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" - boolean deployBothBridges = service == 'event-bridge-service' && - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) - String downstreamImageTag = deployBothBridges - ? "${env.IMAGE_TAG}-both" - : env.IMAGE_TAG - String downstreamBridgeRegion = deployBothBridges - ? 'CN' - : (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN') echo "触发单服务 Job: ${jobName}" def downstreamBuild = build( job: jobName, @@ -441,16 +358,13 @@ pipeline { propagate: false, parameters: [ string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), - string(name: 'IMAGE_TAG', value: downstreamImageTag), + string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), - string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), - // Choice 参数始终传递所有新旧 Job 都接受的单区值; - // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 - string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) + string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' @@ -483,89 +397,4 @@ pipeline { } } - post { - success { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') { - try { - List deployedServices = env.SELECTED_SERVICES?.split(',') - ?.collect { it.trim() } - ?.findAll { it } ?: [] - String serviceSummary = deployedServices.size() <= 3 - ? deployedServices.join('、') - : "${deployedServices.size()} 个服务" - sendProductionWeComNotification( - '✅ 生产部署成功', - '成功', - 'info', - "> 结果:${deployedServices.size()} 个服务全部成功\n" + - "> 部署服务:${serviceSummary ?: '—'}" - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - unstable { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - List failedServices = downstreamResults.findAll { service, result -> result != 'SUCCESS' } - .collect { service, result -> service } - int successCount = downstreamResults.findAll { service, result -> result == 'SUCCESS' }.size() - String failedLinks = failedServices.collect { service -> - String url = downstreamBuildUrls[service] - String number = downstreamBuildNumbers[service] - String label = number ? "${service} #${number}" : service - url ? "[${label}](${url})" : label - }.join('、') - sendProductionWeComNotification( - '⚠️ 生产部署部分失败', - '部分失败', - 'warning', - "> 结果:成功 ${successCount} / 失败 ${failedServices.size()}\n" + - "> 失败服务:${failedLinks ?: '未解析'}\n" + - '> 说明:点击失败服务可直接查看下游构建日志' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - failure { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - sendProductionWeComNotification( - '❌ 生产发布失败', - '失败', - 'warning', - '> 说明:生产发布流程失败,请打开父构建日志查看失败阶段' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - aborted { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - sendProductionWeComNotification( - '⏹ 生产部署已取消', - '已取消', - 'comment', - '> 说明:生产部署流程被人工取消或中止' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - } - } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 2f213a75f..1b34f46dc 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,11 +1,10 @@ def serviceModules = [ - 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service' ] def serviceJars = [ 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', - 'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', @@ -17,18 +16,11 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] def fixedEnvironmentForServiceJob(String jobBaseName) { - if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) { - return 'development' - } - if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) { - return 'production' - } - if (jobBaseName?.startsWith('baiyue-firstleg-')) { + if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) { return 'test' } return null @@ -53,11 +45,11 @@ pipeline { string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') + booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 依次部署国内和海外实例;SELECT 为总控兼容值') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') } environment { @@ -98,12 +90,7 @@ pipeline { error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") } if (params.ENVIRONMENT != resolvedEnvironment) { - echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}" - } - boolean deployBothBridges = service == 'event-bridge-service' && - imageTag?.endsWith('-both') - if (deployBothBridges) { - imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + error('当前单服务流水线仅允许 TEST') } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') @@ -123,14 +110,8 @@ pipeline { env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository - env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false' - if (service == 'event-bridge-service' && params.DEPLOY) { - String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() - if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { - error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') - } - } + } sh ''' @@ -142,15 +123,6 @@ pipeline { test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" ''' - sh ''' - if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || { - echo "生产构建只允许已经合并到 main 的提交" >&2 - exit 1 - } - fi - ''' script { currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}" @@ -163,15 +135,11 @@ pipeline { steps { script { boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean - if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { - error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') - } - boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS + + boolean skipTestExecution = params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' if (params.SKIP_TESTS && coordinatorPinnedCommit) { echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" - } else if (env.RESOLVED_ENVIRONMENT == 'test') { - echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" } @@ -216,105 +184,27 @@ pipeline { } } - stage('部署开发或测试环境') { - when { - beforeAgent true - expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) } - } + stage('部署 TEST') { + when { expression { params.DEPLOY } } agent none steps { script { - String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot" - String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" - String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" - String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' - // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 - // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 - lock(resource: serviceDeployResource, inversePrecedence: true) { - lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { - node(targetLabel) { - deleteDir() - try { - unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } - } - } - } finally { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } - } - } - } - } - } - - stage('部署生产环境单个服务') { - when { - beforeAgent true - expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } - } - agent { label 'java-builder' } - steps { - deleteDir() - unstash 'deployment-files' - script { - String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" - String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - lock(resource: serviceDeployResource, inversePrecedence: true) { + lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) { + node('java-test') { + deleteDir() + unstash 'deployment-files' withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), - sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), - string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), - file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署生产事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services', - 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } + withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) { + sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"' } } + } } } } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } } } diff --git a/README.md b/README.md index cac61cb47..d1cf1d8a7 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,21 @@ 本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。 -整体迁移尚未完成:下面模块清单与旧启动配置描述的是现有源码,海外 Gateway、事件桥和双 Token 仍待替代;不代表需要继续规划双区域或其他环境。 +根 POM 与发布清单已收敛为 20 个模块、13 个服务;本地配置变化尚未经过真实 TEST 部署验收。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,已不参与当前聚合构建和发布。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 ## 模块 +- `gateway`:四端统一 API 入口。 +- `finance`:财务账单、费用、充值、授信与资金核销。 - `auth`:统一登录、令牌签发与刷新。 - `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `order`:订单主数据及订单状态编排。 - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 -- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 +- `lastleg-service`、`warehouse-overseas-service`:海外尾程/入仓/出库/派送和海外仓储资源,统一会话鉴权。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 @@ -31,21 +32,17 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -现有本地启动入口包含 IAM、Auth、Firstleg、Order、Warehouse、File、Print 和 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。旧海外 Compose/Nacos 仍存在;新的测试启动依赖和统一配置按部署主契约收敛,本地编译不代表另有开发环境。 - -国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 +唯一发布配置为 `deploy/docker-compose.yml`、`deploy/env/.env.test.example` 和 `config/nacos/test/`;所有保留服务共用实际 Namespace ID、Auth/Redis 会话和单机内部网络,各服务 Schema 保留。往来单位由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。发布、健康检查和失败恢复见 `deploy/TEST单机部署执行清单.md`。真实凭据不得提交;本地编译不代表另有开发环境。 ## 容器日志 -所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 +所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 -单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖: +单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖: | 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | |---|---|---:|---:|---| -| 开发 | `json-file` | `20m` | `2` | 不要求 | | 测试 | `json-file` | `50m` | `3` | 不要求 | -| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 | 当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。 @@ -53,7 +50,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 ## 网关跨域配置 -本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: +本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: ```text CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com @@ -64,3 +61,5 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。 + +前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。 diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md index 3f9ba8e89..781ddd2ab 100644 --- a/audit-service/AGENTS.md +++ b/audit-service/AGENTS.md @@ -17,7 +17,7 @@ | 入库编排 | `service/AuditIngestService.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 表结构 | `src/main/resources/db/audit.sql` | -| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` | +| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/audit-service.yml` | ## 修改与验证 diff --git a/audit-service/src/main/resources/application-dev.yml b/audit-service/src/main/resources/application-dev.yml deleted file mode 100644 index cd4d4a031..000000000 --- a/audit-service/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} diff --git a/audit-service/src/main/resources/application-test.yml b/audit-service/src/main/resources/application-test.yml deleted file mode 100644 index 5cde9b056..000000000 --- a/audit-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml index 7246521f9..6c658ac7e 100644 --- a/audit-service/src/main/resources/application.yml +++ b/audit-service/src/main/resources/application.yml @@ -3,27 +3,27 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: audit-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - namespace: ${NACOS_NAMESPACE:public} + server-addr: ${NACOS_SERVER_ADDR} + namespace: ${NACOS_NAMESPACE} baiyue.audit.enabled: false baiyue: diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 195b1036b..2fc5501e1 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -18,7 +18,7 @@ | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 | | 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 | -| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | +| 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | @@ -28,7 +28,9 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。 +- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/auth/pom.xml b/auth/pom.xml index 8d1a1d8cb..58bb2d170 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -22,6 +22,7 @@ com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest org.springframework.securityspring-security-testtest + com.h2databaseh2test org.springframework.bootspring-boot-maven-plugin diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java new file mode 100644 index 000000000..238c63f68 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java @@ -0,0 +1,14 @@ +package com.baiyue.auth.client; + +import feign.Request; +import feign.Retryer; +import org.springframework.context.annotation.Bean; +import java.util.concurrent.TimeUnit; + +/** Only the IAM authorization client uses this bounded, single-attempt chain. */ +public class AuthorizationFeignConfiguration { + @Bean public Request.Options authorizationRequestOptions() { + return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false); + } + @Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; } +} diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index a7cae146c..8b2632ae6 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -23,6 +23,14 @@ public class AuthorizationSnapshot { private Long permissionVersion; private Long overseasWarehouseId; private Long domesticWarehouseId; + private Long tenantVersion; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, Long permissionVersion, + Long overseasWarehouseId, Long domesticWarehouseId) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L); + } public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, String clientType, Set roles, Set permissions, diff --git a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java index 5dca79307..3a1d6a019 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader; /** * 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。 */ -@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", +@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class, url = "${baiyue.clients.iam.url:}", path = "/internal/iam") public interface IamAuthorizationClient { @PostMapping("/authorization-snapshot") diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index 966201676..922292fb6 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,15 +3,12 @@ package com.baiyue.auth.controller; import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AuthService; -import com.baiyue.auth.service.OverseasSessionValidationService; -import com.baiyue.auth.dto.OverseasSessionValidationDtos; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -22,14 +19,13 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") -@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; - @Resource - private OverseasSessionValidationService overseasSessionValidationService; + @Resource private com.baiyue.auth.service.SessionStateService sessionStateService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -71,15 +67,17 @@ public class InternalAccountController { @Valid @RequestBody RevokeAccountSessionsRequest request) { verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); } - @PostMapping("/overseas-session/validate") - @Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", - description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.") - public ApiResponse validateOverseasSession( + public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId, + @jakarta.validation.constraints.NotNull Long userId) { } + + @PostMapping("/sessions/validate") + public ApiResponse validateSession( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, - @Valid @RequestBody OverseasSessionValidationDtos.Request request) { + @Valid @RequestBody SessionValidationRequest request) { verify(token); - return ApiResponse.success(overseasSessionValidationService.validate(request)); + return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId())); } + @PutMapping("/{id}/profile") public ApiResponse profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java deleted file mode 100644 index 2ab9a5f3f..000000000 --- a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.baiyue.auth.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */ -public final class OverseasSessionValidationDtos { - private OverseasSessionValidationDtos() { } - - @Schema(name = "OverseasSessionValidationRequest", - description = "Claims extracted only after overseas Gateway verifies the JWT signature") - public record Request( - @NotNull Long userId, - @NotNull Integer tokenVersion, - Long overseasWarehouseId) { } - - @Schema(name = "OverseasSessionValidationResponse") - public record Response(boolean valid, FailureReason failureReason) { - public static Response accepted() { return new Response(true, null); } - public static Response rejected(FailureReason reason) { return new Response(false, reason); } - } - - public enum FailureReason { - ACCOUNT_INVALID, - TOKEN_VERSION_MISMATCH, - WAREHOUSE_UNBOUND, - WAREHOUSE_MISMATCH - } -} diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java index 636a1efe0..6e61ad475 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -19,13 +19,13 @@ public class AuthAccountRepository { public AuthAccount findByUsername(String username) { return mapper.selectOne(new LambdaQueryWrapper() .eq(AuthAccount::getUsername, username) - .last("LIMIT 1")); + .last("LIMIT 1 FOR UPDATE")); } public AuthAccount findByPhone(String phone) { return mapper.selectOne(new LambdaQueryWrapper() .eq(AuthAccount::getPhone, phone) - .last("LIMIT 1")); + .last("LIMIT 1 FOR UPDATE")); } public boolean existsByUsername(String username) { @@ -53,13 +53,15 @@ public class AuthAccountRepository { public void insert(AuthAccount account) { mapper.insert(account); } public AuthAccount findById(Long id) { - return mapper.selectById(id); + return mapper.selectOne(new LambdaQueryWrapper().eq(AuthAccount::getId, id).last("FOR UPDATE")); } + public AuthAccount findCurrentById(Long id) { return mapper.selectById(id); } + public List findMerchantByTenant(Long tenantId) { return mapper.selectList(new LambdaQueryWrapper() .eq(AuthAccount::getDefaultTenantId, tenantId) - .eq(AuthAccount::getDefaultAppCode, "MERCHANT")); + .eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE")); } public void save(AuthAccount account) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 6c64184e1..a8877efa2 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -108,7 +108,7 @@ public class AccountManagementService { resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话", captureBefore = false, captureAfter = false) public void revokeSessions(Set accountIds) { - accountIds.forEach(accountId -> bumpVersion(required(accountId))); + accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId))); } @Transactional diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index d3b76d480..36c987ec2 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -92,7 +92,8 @@ public class AuthService { AuthAccount account = accountRepository.findById(session.userId()); if (account == null || !"ACTIVE".equals(account.getAccountStatus())) throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); - return issue(account); + if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态"); + return issue(account, session.fingerprint()); } @Transactional @@ -109,8 +110,13 @@ public class AuthService { accountRepository.save(account); } - private TokenResponse issue(AuthAccount account) { + private TokenResponse issue(AuthAccount account) { return issue(account, null); } + + private TokenResponse issue(AuthAccount account, String expectedState) { AuthorizationSnapshot authorization = authorizationLoader.load(account); + if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) { + throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录"); + } Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); @@ -145,7 +151,8 @@ public class AuthService { byte[] random = new byte[48]; secureRandom.nextBytes(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); - tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, + tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), + SessionStateService.fingerprint(account, authorization), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(), @@ -202,7 +209,7 @@ public class AuthService { .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), - authorization.getPermissions(), Duration.ofMinutes(15)); + authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15)); return new ImpersonationTokenResponse("Bearer", accessToken, 900, authorization.getRoles(), authorization.getPermissions()); } diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java deleted file mode 100644 index 0f566a290..000000000 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Service; - -import java.util.Objects; - -/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */ -@Service -public class OverseasSessionValidationService { - @Resource private AuthAccountRepository accounts; - @Resource private AuthorizationLoader authorizationLoader; - - public Response validate(Request request) { - AuthAccount account = accounts.findById(request.userId()); - if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { - return Response.rejected(FailureReason.ACCOUNT_INVALID); - } - int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion(); - if (currentVersion != request.tokenVersion()) { - return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH); - } - Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId(); - if (currentWarehouseId == null || request.overseasWarehouseId() == null) { - return Response.rejected(FailureReason.WAREHOUSE_UNBOUND); - } - if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) { - return Response.rejected(FailureReason.WAREHOUSE_MISMATCH); - } - return Response.accepted(); - } -} diff --git a/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java b/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java new file mode 100644 index 000000000..69b7480f5 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java @@ -0,0 +1,63 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.TreeSet; + +/** No cache: a validation started after a security commit must observe that commit. */ +@Service +public class SessionStateService { + private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper(); + @Resource private AuthAccountRepository accounts; + @Resource private AuthorizationLoader authorizationLoader; + @Resource private TokenSessionStore sessions; + + public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) { + try { + if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null + || snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version"); + // Ordered JSON array avoids set ordering and delimiter ambiguities. + Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(), + account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(), + snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(), + snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(), + new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())}; + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(FINGERPRINT_JSON.writeValueAsBytes(state))); + } catch (Exception failure) { + throw new IllegalStateException("Cannot fingerprint authorization state", failure); + } + } + + private void requireCurrent(Long userId, String expected) { + AuthAccount account = accounts.findCurrentById(userId); + if (account == null || !"ACTIVE".equals(account.getAccountStatus()) + || Integer.valueOf(1).equals(account.getDeleted()) || expected == null + || !expected.equals(fingerprint(account, authorizationLoader.load(account)))) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录"); + } + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW, + isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public boolean validate(String jwtId, Long userId) { + TokenSessionStore.SessionState state = sessions.loadState(jwtId); + if (state == null || !state.userId().equals(userId)) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效"); + } + requireCurrent(userId, state.fingerprint()); + return true; + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index fbfefb41b..5fb10f1b1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.SetOperations; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; @@ -21,8 +19,6 @@ import java.security.MessageDigest; import java.time.Duration; import java.util.HexFormat; import java.util.Set; -import java.util.ArrayList; -import java.util.List; /** * 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。 @@ -42,7 +38,7 @@ public class TokenSessionStore { } public void store(String jwtId, Long userId, Set roles, Set permissions, - String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { + String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String userKey = userSessionsKey(userId); @@ -50,25 +46,27 @@ public class TokenSessionStore { String accessValue = objectMapper.writeValueAsString( new AccessSession(1, userId, normalized(roles), normalized(permissions))); redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); - String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); + storeState(jwtId, userId, fingerprint, accessTtl); + String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint)); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); SetOperations sets = redisTemplate.opsForSet(); - if (sets != null) sets.add(userKey, accessKey, refreshKey); + if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId)); redisTemplate.expire(userKey, refreshTtl); } catch (Exception exception) { - try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { } + try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { } throw unavailable(exception); } } public void storeAccessOnly(String jwtId, Long userId, Set roles, - Set permissions, Duration ttl) { + Set permissions, String fingerprint, Duration ttl) { try { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessValue = objectMapper.writeValueAsString( new AccessSession(1, userId, normalized(roles), normalized(permissions))); redisTemplate.opsForValue().set(accessKey, accessValue, ttl); - redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey); + storeState(jwtId, userId, fingerprint, ttl); + redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId)); redisTemplate.expire(userSessionsKey(userId), ttl); } catch (Exception exception) { throw unavailable(exception); @@ -103,6 +101,7 @@ public class TokenSessionStore { if (value == null || value.isBlank()) return null; RefreshSession session = objectMapper.readValue(value, RefreshSession.class); redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId())); + redisTemplate.delete(stateKey(session.accessJwtId())); return session; } catch (DataAccessException exception) { throw unavailable(exception); @@ -121,43 +120,26 @@ public class TokenSessionStore { try { Set keys = redisTemplate.opsForSet().members(userKey); if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys); - // 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。 - List legacyKeys = new ArrayList<>(); - collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false); - collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true); - if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys); redisTemplate.delete(userKey); } catch (DataAccessException exception) { throw unavailable(exception); } } - private void collectLegacyKeys(String pattern, Long userId, List result, boolean refresh) { - Cursor cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build()); - if (cursor == null) return; - try (cursor) { - while (cursor.hasNext()) { - String key = cursor.next(); - String value = redisTemplate.opsForValue().get(key); - if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) { - result.add(key); - } - } - } catch (Exception exception) { - throw unavailable(exception); - } + private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; } + + private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception { + if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state"); + redisTemplate.opsForValue().set(stateKey(jwtId), + objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl); } - private boolean belongsTo(String value, Long userId) { - if (value == null || value.isBlank()) return false; - try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); } - catch (Exception ignored) { return false; } - } - - private boolean accessBelongsTo(String value, Long userId) { - if (value == null || value.isBlank()) return false; - try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); } - catch (Exception ignored) { return false; } + public SessionState loadState(String jwtId) { + try { + if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null; + String value = redisTemplate.opsForValue().get(stateKey(jwtId)); + return value == null ? null : objectMapper.readValue(value, SessionState.class); + } catch (Exception failure) { throw unavailable(failure); } } private static Set normalized(Set values) { @@ -182,7 +164,8 @@ public class TokenSessionStore { } } - public record RefreshSession(Long userId, String accessJwtId) { } + public record SessionState(Long userId, String fingerprint) { } + public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { } public record AccessSession(int version, Long userId, Set roles, Set permissions) { public AccessSession { roles = normalized(roles); diff --git a/auth/src/main/resources/application-dev.yml b/auth/src/main/resources/application-dev.yml deleted file mode 100644 index 1c0753a99..000000000 --- a/auth/src/main/resources/application-dev.yml +++ /dev/null @@ -1,10 +0,0 @@ -spring: - cloud: - nacos: - discovery: - ip: ${NACOS_DISCOVERY_IP:127.0.0.1} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} - password: ${AUTH_DB_PASSWORD:123456} diff --git a/auth/src/main/resources/application-test.yml b/auth/src/main/resources/application-test.yml deleted file mode 100644 index 07c7f96b5..000000000 --- a/auth/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index ad9451d45..6acb857eb 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -2,35 +2,35 @@ server: port: ${AUTH_PORT:9100} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: auth-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true data: redis: - host: ${REDIS_HOST:127.0.0.1} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: ${REDIS_TIMEOUT:3s} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue.security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} @@ -40,7 +40,7 @@ baiyue.security: lock-duration: ${LOGIN_LOCK_DURATION:15m} jwt-mode: jwks session-mode: redis - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} jwt: rsa-jwk-set: ${JWT_RSA_JWK_SET:} current-kid: ${JWT_CURRENT_KID:} @@ -50,3 +50,5 @@ baiyue.clients: url: ${IAM_SERVICE_URL:} management.endpoints.web.exposure.include: health,info,prometheus + +baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate} diff --git a/auth/src/main/resources/openapi/auth-internal.yaml b/auth/src/main/resources/openapi/auth-internal.yaml new file mode 100644 index 000000000..4972b5463 --- /dev/null +++ b/auth/src/main/resources/openapi/auth-internal.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Auth internal session validation + version: 1.0.0 +paths: + /internal/auth/accounts/sessions/validate: + post: + summary: Validate persisted account and IAM authorization state + description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token. + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [jwtId, userId] + properties: + jwtId: {type: string, minLength: 1} + userId: {type: integer, format: int64} + responses: + '200': + description: Only string code 200 and boolean data true constitute success. Business errors deny access. + content: + application/json: + schema: + type: object + required: [code] + properties: + code: {type: string} + message: {type: string} + data: {type: boolean, nullable: true} +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token diff --git a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java deleted file mode 100644 index 7c698a724..000000000 --- a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.auth.controller; - -import com.baiyue.auth.dto.OverseasSessionValidationDtos; -import io.swagger.v3.oas.annotations.Operation; -import org.junit.jupiter.api.Test; -import org.springframework.web.bind.annotation.PostMapping; - -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; - -class OverseasSessionValidationControllerContractTest { - @Test - void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() { - var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods()) - .filter(candidate -> candidate.getName().equals("validateOverseasSession")) - .findFirst().orElseThrow(); - - assertThat(method.getAnnotation(PostMapping.class).value()) - .containsExactly("/overseas-session/validate"); - assertThat(method.getAnnotation(Operation.class).description()) - .contains("Gateway JWT verification", "raw tokens and user credentials are forbidden"); - assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents()) - .extracting(java.lang.reflect.RecordComponent::getName) - .containsExactly("userId", "tokenVersion", "overseasWarehouseId") - .doesNotContain("token", "accessToken", "authorization", "password"); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index f908f1ab3..d8f367e62 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -93,7 +93,7 @@ class AuthServiceSmallJwtTest { assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current"); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), - org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), + org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); @@ -156,7 +156,7 @@ class AuthServiceSmallJwtTest { Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(tokenSessionStore.consume("refresh-old")).thenReturn( - new TokenSessionStore.RefreshSession(52L, "refresh-session")); + new TokenSessionStore.RefreshSession(52L, "refresh-session", SessionStateService.fingerprint(account, snapshot))); when(repository.findById(52L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); @@ -175,7 +175,7 @@ class AuthServiceSmallJwtTest { verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), - org.mockito.ArgumentMatchers.eq(response.refreshToken()), + anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); @@ -231,6 +231,6 @@ class AuthServiceSmallJwtTest { verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), - org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); + anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); } } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java deleted file mode 100644 index c076584bf..000000000 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class OverseasSessionValidationServiceTest { - private final AuthAccountRepository accounts = mock(AuthAccountRepository.class); - private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class); - private final OverseasSessionValidationService service = new OverseasSessionValidationService(); - private AuthAccount account; - - @BeforeEach - void setUp() throws Exception { - inject("accounts", accounts); - inject("authorizationLoader", authorizationLoader); - account = new AuthAccount(); - account.setId(42L); - account.setAccountStatus("ACTIVE"); - account.setTokenVersion(8); - when(accounts.findById(42L)).thenReturn(account); - } - - @Test - void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() { - assertThat(service.validate(new Request(42L, 7, 900L))) - .extracting("valid", "failureReason") - .containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH); - } - - @Test - void newlyIssuedTokenWithCurrentWarehousePasses() { - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); - - assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue(); - } - - @Test - void missingOrChangedWarehouseBindingFailsClosed() { - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null)); - assertThat(service.validate(new Request(42L, 8, null)).failureReason()) - .isEqualTo(FailureReason.WAREHOUSE_UNBOUND); - - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); - assertThat(service.validate(new Request(42L, 8, 900L)).failureReason()) - .isEqualTo(FailureReason.WAREHOUSE_MISMATCH); - } - - @Test - void disabledAccountFailsClosedBeforeIamLookup() { - account.setAccountStatus("DISABLED"); - assertThat(service.validate(new Request(42L, 8, 901L)).failureReason()) - .isEqualTo(FailureReason.ACCOUNT_INVALID); - } - - private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { - return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId); - } - - private void inject(String fieldName, Object value) throws Exception { - var field = OverseasSessionValidationService.class.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(service, value); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java b/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java new file mode 100644 index 000000000..6f1af9625 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java @@ -0,0 +1,281 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.AuthSecurityProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; +import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest; +import com.baiyue.auth.dto.LoginRequest; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services. + * Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */ +class SessionStateConcurrencyTest { + JdbcTemplate authDb, iamDb; + DataSourceTransactionManager authTx, iamTx; + AuthService auth; + AccountManagementService accounts; + SessionStateService states; + TokenSessionStore sessions; + JwtEncoder encoder; + AtomicBoolean redisFailure; + final ConcurrentMap values = new ConcurrentHashMap<>(); + final ConcurrentMap> sets = new ConcurrentHashMap<>(); + + @BeforeEach void setup() throws Exception { + authDb = database(); iamDb = database(); + authTx = new DataSourceTransactionManager(authDb.getDataSource()); + iamTx = new DataSourceTransactionManager(iamDb.getDataSource()); + authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50)," + + "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT," + + "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT," + + "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT," + + "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id," + + "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)"); + iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)"); + iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)"); + + var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean(); + factory.setDataSource(authDb.getDataSource()); + var config = new com.baomidou.mybatisplus.core.MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class); + factory.setConfiguration(config); + var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()) + .getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class); + var repository = new AuthAccountRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + AuthorizationLoader loader = mock(AuthorizationLoader.class); + when(loader.load(any())).thenAnswer(call -> { + var tx = new TransactionTemplate(iamTx); + tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ); + return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> { + if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled"); + return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"), + Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null, + rs.getLong("tenant_version")); + })); + }); + sessions = new TokenSessionStore(); + ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(sessions, "keyPrefix", "test"); + ReflectionTestUtils.setField(sessions, "redisTemplate", redis()); + PasswordEncoder passwords = mock(PasswordEncoder.class); + when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1))); + when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0)); + encoder = mock(JwtEncoder.class); + when(encoder.encode(any())).thenAnswer(c -> { + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }); + var service = new AuthService(); + ReflectionTestUtils.setField(service, "accountRepository", repository); + ReflectionTestUtils.setField(service, "authorizationLoader", loader); + ReflectionTestUtils.setField(service, "tokenSessionStore", sessions); + ReflectionTestUtils.setField(service, "passwordEncoder", passwords); + ReflectionTestUtils.setField(service, "jwtEncoder", encoder); + ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1))); + ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid")); + auth = transactional(service); + var management = new AccountManagementService(); + ReflectionTestUtils.setField(management, "repository", repository); + ReflectionTestUtils.setField(management, "tokenSessionStore", sessions); + ReflectionTestUtils.setField(management, "passwordEncoder", passwords); + accounts = transactional(management); + var state = new SessionStateService(); + ReflectionTestUtils.setField(state, "accounts", repository); + ReflectionTestUtils.setField(state, "authorizationLoader", loader); + ReflectionTestUtils.setField(state, "sessions", sessions); + states = transactional(state); + } + + @Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception { + var revoked = new CountDownLatch(1); + var snapshotLoaded = new CountDownLatch(1); + var committed = new CountDownLatch(1); + doAnswer(c -> { + snapshotLoaded.countDown(); await(committed); + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }).when(encoder).encode(any()); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future change = pool.submit(() -> { + new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + revoked.countDown(); await(snapshotLoaded); + }); + committed.countDown(); + }); + await(revoked); + var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password"))); + var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS); + // Access and state were written AFTER revoke, yet the first post-commit request is denied. + assertThat(values).containsKey("test:access:" + stale.accessToken()); + assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(fresh.accessToken(), 1L)).isTrue(); + assertThat(fresh.authorities()).containsExactly("read"); + assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class); + } finally { committed.countDown(); pool.shutdownNow(); } + } + + @Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception { + var loaded = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(c -> { + loaded.countDown(); await(release); + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }).when(encoder).encode(any()); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password"))); + await(loaded); + var resetting = new CountDownLatch(1); + var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); }); + await(resetting); + assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + release.countDown(); + var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() { + var old = auth.login(new LoginRequest("operator", "old-password")); + redisFailure.set(true); + assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + })).isInstanceOf(BizException.class); + redisFailure.set(false); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1); + assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1); + assertThat(states.validate(old.accessToken(), 1L)).isTrue(); + } + + @Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() { + var old = auth.login(new LoginRequest("operator", "old-password")); + new TransactionTemplate(iamTx).executeWithoutResult(tx -> + iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1")); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class); + var current = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(current.accessToken(), 1L)).isTrue(); + iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1"); + assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class); + } + + @Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() { + var old = auth.login(new LoginRequest("operator", "old-password")); + new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + tx.setRollbackOnly(); + }); + assertThat(states.validate(old.accessToken(), 1L)).isTrue(); + assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class); + var refreshed = auth.refresh(old.refreshToken()); + assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue(); + } + + @Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() { + var old = auth.login(new LoginRequest("operator", "old-password")); + assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + throw new IllegalStateException("Auth response timed out after its commit"); + })).isInstanceOf(IllegalStateException.class); + assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write"); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(fresh.accessToken(), 1L)).isTrue(); + assertThat(fresh.authorities()).containsExactly("write"); + } + + @Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() { + var old = auth.login(new LoginRequest("operator", "old-password")); + // No Redis deletion: the authoritative identity gate alone closes the session. + iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1"); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class); + iamDb.update("UPDATE authorization_state SET active=true WHERE id=1"); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + iamDb.execute("DROP TABLE authorization_state"); + assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class); + assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class); + } + + @SuppressWarnings("unchecked") private StringRedisTemplate redis() { + redisFailure = new AtomicBoolean(); + var redis = mock(StringRedisTemplate.class); + ValueOperations ops = mock(ValueOperations.class); + SetOperations index = mock(SetOperations.class); + when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index); + doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class)); + when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0))); + when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0))); + when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0))); + when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null); + when(redis.delete(anyCollection())).thenAnswer(c -> { + if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline"); + long count = 0; for (Object key : (java.util.Collection) c.getArgument(0)) if (values.remove(key) != null) count++; + return count; + }); + when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of())); + doAnswer(c -> { + Set members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet()); + for (int i=1; i T transactional(T target) { + var factory = new ProxyFactory(target); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource())); + return (T) factory.getProxy(); + } + static void await(CountDownLatch latch) { + try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); } + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index a18fc513d..760896079 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -47,7 +47,7 @@ class TokenSessionStoreTest { when(redis.opsForValue()).thenReturn(values); store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), - "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); + "state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); verify(values).set(eq("baiyue:auth:access:jti-1"), org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") @@ -64,7 +64,7 @@ class TokenSessionStoreTest { ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); String json = new ObjectMapper().writeValueAsString( - new TokenSessionStore.RefreshSession(42L, "jti-1")); + new TokenSessionStore.RefreshSession(42L, "jti-1", "state")); when(values.getAndDelete(anyString())).thenReturn(json); TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 98ad33565..564b68cfd 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -15,6 +15,7 @@ | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | | 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 | +| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | ## 修改与验证 diff --git a/common-core/pom.xml b/common-core/pom.xml index faff84fd8..9aef71cb6 100644 --- a/common-core/pom.xml +++ b/common-core/pom.xml @@ -24,5 +24,7 @@ spring-boot-starter-test test + org.springframeworkspring-jdbc + com.h2databaseh2test diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java new file mode 100644 index 000000000..c5b00f76b --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java @@ -0,0 +1,59 @@ +package com.baiyue.common.core.outbox; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.function.IntSupplier; + +/** Same-database audit. HTTP must only run after begin returns; never hold a DB transaction over HTTP. */ +public final class EventDeliveryJournal { + public enum Outcome { APPLIED, DUPLICATE, STALE, RETRY, DEAD, LEASE_EXPIRED } + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + + public EventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { + this.jdbc = Objects.requireNonNull(jdbc); + if (!(manager instanceof DataSourceTransactionManager local) + || local.getDataSource() != jdbc.getDataSource()) { + throw new IllegalArgumentException("Delivery audit must share the local Outbox transaction manager"); + } + transaction = new TransactionTemplate(manager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public boolean begin(String attempt, String service, String eventType, String entryId, + String eventId, String target, LocalDateTime now, BooleanSupplier claim) { + return Boolean.TRUE.equals(transaction.execute(status -> { + if (!claim.getAsBoolean()) return false; + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES(?,?,?,?,?,?,?,'STARTED')", + attempt, service, eventType, entryId, eventId, target, now); + return true; + })); + } + + public boolean complete(String attempt, Outcome outcome, LocalDateTime now, + String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) { + if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode) + && !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code"); + return Boolean.TRUE.equals(transaction.execute(status -> { + int changed = updateOwnedOutbox.getAsInt(); + if (changed == 0) return false; // A stale worker has no right to change either record. + if (changed != 1) throw new IllegalStateException("Unexpected Outbox update count"); + LocalDateTime started = jdbc.queryForObject( + "SELECT started_at FROM event_delivery_attempt WHERE attempt_id=? AND outcome='STARTED' FOR UPDATE", + LocalDateTime.class, attempt); + long duration = Math.max(0, Duration.between(Objects.requireNonNull(started), now).toMillis()); + int audited = jdbc.update("UPDATE event_delivery_attempt SET finished_at=?,duration_ms=?,outcome=?,error_code=?,next_retry_at=? WHERE attempt_id=? AND outcome='STARTED'", + now, duration, outcome.name(), errorCode, nextRetry, attempt); + if (audited != 1) throw new IllegalStateException("Delivery completion audit is missing"); + return true; + })); + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java new file mode 100644 index 000000000..32dc2a7be --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java @@ -0,0 +1,4 @@ +package com.baiyue.common.core.outbox; + +/** Marks services that require durable attempt audit and cached delivery metrics. */ +public interface EventDeliveryProducer { } diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java new file mode 100644 index 000000000..23d1b8934 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.common.core.outbox; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Dedicated operations credential; a missing or weak configuration disables this capability. */ +public final class EventOperationsTokenGuard { + private final byte[] expected; + public EventOperationsTokenGuard(String token) { + expected = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + } + public void require(String supplied) { + if (expected.length < 32 || supplied == null || supplied.length() > 4096 + || !MessageDigest.isEqual(expected, supplied.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "事件运维凭据无效或入口未启用"); + } + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java new file mode 100644 index 000000000..cdeaf1f48 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java @@ -0,0 +1,129 @@ +package com.baiyue.common.core.outbox; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; + +/** Transactional operator retry of an immutable, service-owned Outbox entry. No cross-schema access. */ +public final class OutboxOperations { + public record Layout(String table, String idColumn, String statusColumn, Set eventTypes) { + public Layout { + for (String name : List.of(table, idColumn, statusColumn)) { + if (!name.matches("[a-z][a-z0-9_]*")) throw new IllegalArgumentException("Invalid Outbox identifier"); + } + eventTypes = Set.copyOf(eventTypes); + if (eventTypes.isEmpty()) throw new IllegalArgumentException("Event allowlist is required"); + } + } + public record Identity(String eventId, String eventType, String payloadHash) { } + public record RetryRequest(String requestId, String operator, String reason, String expectedPayloadHash) { } + public record RetryResult(String entryId, String eventId, String payloadHash, String requestId, boolean duplicate) { } + public record Entry(String entryId, String eventId, String eventType, String payloadHash, + String status, int failures, String lastError, Object nextRetryAt) { } + private final JdbcTemplate jdbc; + private final Layout layout; + private final Function identityReader; + private final TransactionTemplate transaction; + + public OutboxOperations(JdbcTemplate jdbc, PlatformTransactionManager transactions, Layout layout, + Function identityReader) { + this.jdbc = jdbc; + this.layout = layout; + this.identityReader = identityReader; + this.transaction = new TransactionTemplate(transactions); + } + + public Entry inspect(String entryId) { + validateId(entryId); + Map row = find(entryId, false); + Identity identity = identity(row); + return new Entry(String.valueOf(row.get("entry_id")), identity.eventId(), identity.eventType(), identity.payloadHash(), + String.valueOf(row.get("entry_status")), ((Number) row.get("retry_count")).intValue(), + (String) row.get("last_error"), row.get("next_retry_at")); + } + + public RetryResult retry(String entryId, RetryRequest request) { + validateId(entryId); + validateRequest(request); + try { + return transaction.execute(status -> { + Map row = find(entryId, true); + String canonicalId = String.valueOf(row.get("entry_id")); + Identity identity = identity(row); + if (!identity.payloadHash().equalsIgnoreCase(request.expectedPayloadHash())) { + throw conflict("事件摘要已变化,拒绝重试"); + } + List> prior = jdbc.queryForList( + "SELECT producer_table,entry_id,event_id,payload_hash,operator_name,reason FROM event_delivery_retry_audit WHERE request_id=?", + request.requestId()); + if (!prior.isEmpty()) { + Map audit = prior.get(0); + if (!layout.table().equals(audit.get("producer_table")) || !canonicalId.equals(audit.get("entry_id")) + || !identity.eventId().equals(audit.get("event_id")) + || !identity.payloadHash().equals(audit.get("payload_hash")) + || !request.operator().equals(audit.get("operator_name")) || !request.reason().equals(audit.get("reason"))) { + throw conflict("操作请求编号已用于不同重试参数"); + } + return result(canonicalId, identity, request, true); + } + if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件"); + LocalDateTime now = LocalDateTime.now(); + int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn() + + "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE " + + layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId); + if (updated != 1) throw conflict("事件状态已变化"); + jdbc.update("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + request.requestId(), layout.table(), canonicalId, identity.eventId(), identity.eventType(), identity.payloadHash(), + request.operator(), request.reason(), row.get("retry_count"), row.get("last_error"), now); + return result(canonicalId, identity, request, false); + }); + } catch (DuplicateKeyException duplicate) { + throw conflict("操作请求编号冲突,原事务已回滚"); + } + } + + private Map find(String id, boolean lock) { + List> rows = jdbc.queryForList("SELECT " + layout.idColumn() + " AS entry_id,payload_json," + layout.statusColumn() + + " AS entry_status,retry_count,last_error,next_retry_at FROM " + layout.table() + " WHERE " + + layout.idColumn() + "=?" + (lock ? " FOR UPDATE" : ""), id); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "事件不存在"); + return rows.get(0); + } + private Identity identity(Map row) { + Identity identity = identityReader.apply(String.valueOf(row.get("payload_json"))); + if (!layout.eventTypes().contains(identity.eventType())) throw new BizException(ResultCode.FORBIDDEN, "事件不属于本运维入口"); + return identity; + } + private static RetryResult result(String entryId, Identity identity, RetryRequest request, boolean duplicate) { + return new RetryResult(entryId, identity.eventId(), identity.payloadHash(), request.requestId(), duplicate); + } + private static void validateId(String entryId) { + if (entryId == null || !entryId.matches("[A-Za-z0-9-]{1,64}")) throw new BizException(ResultCode.BAD_REQUEST, "事件记录标识无效"); + } + private static void validateRequest(RetryRequest request) { + if (request == null) throw new BizException(ResultCode.BAD_REQUEST, "重试参数不能为空"); + try { + if (!UUID.fromString(request.requestId()).toString().equalsIgnoreCase(request.requestId())) throw new IllegalArgumentException(); + } catch (RuntimeException failure) { throw new BizException(ResultCode.BAD_REQUEST, "requestId 必须为标准 UUID"); } + requireText(request.operator(), 128, "操作人"); + requireText(request.reason(), 500, "原因"); + if (request.expectedPayloadHash() == null || !request.expectedPayloadHash().matches("sha256:[0-9a-fA-F]{64}")) { + throw new BizException(ResultCode.BAD_REQUEST, "必须提供预期事件摘要"); + } + } + private static void requireText(String value, int length, String label) { + if (value == null || value.isBlank() || value.length() > length || value.chars().anyMatch(Character::isISOControl)) { + throw new BizException(ResultCode.BAD_REQUEST, label + "无效"); + } + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java b/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java new file mode 100644 index 000000000..48b4f0083 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java @@ -0,0 +1,83 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryJournalTest { + JdbcTemplate jdbc; + EventDeliveryJournal journal; + DataSourceTransactionManager transactions; + @BeforeEach void setup() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + transactions = new DataSourceTransactionManager(source); + journal = new EventDeliveryJournal(jdbc, transactions); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + jdbc.execute("CREATE TABLE work(id INT PRIMARY KEY,state VARCHAR(20),owner VARCHAR(36))"); + jdbc.update("INSERT INTO work VALUES(1,'PENDING',NULL)"); + } + boolean begin(String owner) { + return journal.begin(owner, "firstleg", "FirstLegLoadingPlanCompleted", "1", UUID.randomUUID().toString(), "lastleg-events", LocalDateTime.now(), + () -> jdbc.update("UPDATE work SET state='PROCESSING',owner=? WHERE id=1 AND state='PENDING'", owner) == 1); + } + int finish(String owner) { return jdbc.update("UPDATE work SET state='PUBLISHED',owner=NULL WHERE id=1 AND owner=?", owner); } + String state() { return jdbc.queryForObject("SELECT state FROM work WHERE id=1", String.class); } + String outcome(String attempt) { return jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, attempt); } + + @Test void startedIsCommittedBeforeCallerContinuesEvenWithinAnOuterTransaction() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + assertThat(begin("a")).isTrue(); + // Another connection sees STARTED before any HTTP would be invoked. + var other = new JdbcTemplate(jdbc.getDataSource()); + assertThat(other.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + status.setRollbackOnly(); + }); + assertThat(state()).isEqualTo("PROCESSING"); + assertThat(outcome("a")).isEqualTo("STARTED"); // process interruption leaves diagnostic evidence + assertThat(begin("b")).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_attempt", Integer.class)).isEqualTo(1); + } + @Test void startAuditFailureRollsBackTheClaim() { + assertThat(begin("a")).isTrue(); + jdbc.update("UPDATE work SET state='PENDING',owner=NULL"); + assertThatThrownBy(() -> begin("a")).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PENDING"); + } + @Test void completionAuditFailureRollsBackOutboxAndPreservesStartedRecord() { + begin("a"); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PROCESSING"); + assertThat(outcome("a")).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isTrue(); + assertThat(state()).isEqualTo("PUBLISHED"); + assertThat(outcome("a")).isEqualTo("APPLIED"); + assertThat(jdbc.queryForObject("SELECT duration_ms FROM event_delivery_attempt", Long.class)).isNotNegative(); + } + @Test void recoveryAndLateResultCannotOverwriteTheNewOwnerOrItsAudit() { + begin("a"); + var retryAt = LocalDateTime.now().plusSeconds(5); + assertThat(journal.complete("a", Outcome.LEASE_EXPIRED, LocalDateTime.now(), "LEASE_EXPIRED", retryAt, + () -> jdbc.update("UPDATE work SET state='PENDING',owner=NULL WHERE owner='a'"))).isTrue(); + begin("b"); + assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isFalse(); + assertThat(outcome("a")).isEqualTo("LEASE_EXPIRED"); + assertThat(outcome("b")).isEqualTo("STARTED"); + assertThat(state()).isEqualTo("PROCESSING"); + } + @Test void unexpectedCompletionWithoutStartedRecordRollsBackInsteadOfUsingAFallback() { + jdbc.update("UPDATE work SET state='PROCESSING',owner='missing'"); + assertThatThrownBy(() -> journal.complete("missing", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("missing"))).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PROCESSING"); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java b/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java new file mode 100644 index 000000000..688006950 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java @@ -0,0 +1,23 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class EventOperationsTokenGuardTest { + @Test void missingOrShortConfigurationDisablesOperations() { + for (String configured : new String[] {null, "", "short"}) { + var guard = new EventOperationsTokenGuard(configured); + assertThatThrownBy(() -> guard.require(configured)).isInstanceOf(BizException.class); + } + } + @Test void onlyExactCredentialIsAccepted() { + String token = "operation-secret-".repeat(3); + var guard = new EventOperationsTokenGuard(token); + assertThatCode(() -> guard.require(token)).doesNotThrowAnyException(); + for (String supplied : new String[] {null, "", token + " ", token.toUpperCase(), "a".repeat(4097)}) { + assertThatThrownBy(() -> guard.require(supplied)).isInstanceOf(BizException.class); + } + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java new file mode 100644 index 000000000..43096352d --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java @@ -0,0 +1,88 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.*; + +class OutboxOperationsTest { + private static final String HASH = "sha256:" + "a".repeat(64); + private JdbcTemplate jdbc; + private OutboxOperations operations; + + @BeforeEach void setup() { + var source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE events(id VARCHAR(64) PRIMARY KEY,payload_json VARCHAR(200),status VARCHAR(20),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(100),locked_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)"); + operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(source), + new OutboxOperations.Layout("events", "id", "status", Set.of("business.completed")), + payload -> new OutboxOperations.Identity(payload, "business.completed", HASH)); + jdbc.update("INSERT INTO events VALUES('1','event-1','DEAD',12,'timeout',CURRENT_TIMESTAMP,'old-worker',CURRENT_TIMESTAMP)"); + } + private OutboxOperations.RetryRequest request() { + return new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "operator-7", "dependency restored", HASH); + } + + @Test void retryPreservesPayloadAndAuditsPreviousFailureThenDeduplicates() { + var request = request(); + assertThat(operations.retry("1", request).duplicate()).isFalse(); + assertThat(operations.retry("1", request).duplicate()).isTrue(); + assertThat(jdbc.queryForMap("SELECT * FROM events WHERE id='1'")) + .containsEntry("PAYLOAD_JSON", "event-1").containsEntry("STATUS", "PENDING") + .containsEntry("RETRY_COUNT", 0).containsEntry("LOCKED_BY", null).containsEntry("LAST_ERROR", null); + assertThat(jdbc.queryForMap("SELECT * FROM event_delivery_retry_audit")) + .containsEntry("PREVIOUS_FAILURES", 12).containsEntry("PREVIOUS_ERROR", "timeout") + .containsEntry("OPERATOR_NAME", "operator-7").containsEntry("PAYLOAD_HASH", HASH); + } + + @Test void auditInsertFailureRollsBackRequeue() { + jdbc.execute("ALTER TABLE event_delivery_retry_audit ADD CONSTRAINT reject_audit CHECK(previous_failures < 0)"); + assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(operations.inspect("1").status()).isEqualTo("DEAD"); + assertThat(operations.inspect("1").failures()).isEqualTo(12); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero(); + } + + @Test void staleHashAndNonDeadAndReusedRequestAreRejected() { + var original = request(); + var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64)); + assertThatThrownBy(() -> operations.retry("1", stale)).isInstanceOf(BizException.class); + operations.retry("1", original); + assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(BizException.class); + var changed = new OutboxOperations.RetryRequest(original.requestId(), "someone-else", original.reason(), HASH); + assertThatThrownBy(() -> operations.retry("1", changed)).isInstanceOf(BizException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + } + + @Test void concurrentSameRequestHasOneAuditAndOneDuplicate() throws Exception { + var request = request(); + var start = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { start.await(); return operations.retry("1", request); }); + var second = pool.submit(() -> { start.await(); return operations.retry("1", request); }); + start.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).duplicate()).isNotEqualTo(second.get(10, TimeUnit.SECONDS).duplicate()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + } finally { pool.shutdownNow(); } + } + + @Test void crossEntryRequestCollisionRollsBackSecondEntry() { + jdbc.update("INSERT INTO events VALUES('2','event-2','DEAD',12,'timeout',CURRENT_TIMESTAMP,NULL,NULL)"); + var request = request(); + operations.retry("1", request); + assertThatThrownBy(() -> operations.retry("2", request)).isInstanceOf(BizException.class); + assertThat(operations.inspect("2").status()).isEqualTo("DEAD"); + } +} diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index c1ce19dbe..6a8721622 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -20,7 +20,7 @@ | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | -| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | +| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | @@ -29,7 +29,7 @@ ## 修改与验证 -- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 +- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java index d4cc2775b..b4d91c3d3 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; /** - * 跨服务和跨区域传输使用的不可变事件信封。 + * 统一测试环境中跨业务服务传输使用的不可变事件信封。 * *

租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。 * {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。

@@ -14,8 +14,6 @@ import java.time.Instant; * @param eventType 事件类型,不包含版本号 * @param eventVersion 事件契约版本,从 1 开始 * @param occurredAt 业务事实实际发生时间,使用 UTC 时间点 - * @param sourceRegion 事件来源业务单元 - * @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同 * @param tenantId 事件所属客户租户 ID * @param aggregateType 聚合类型 * @param aggregateId 聚合 ID @@ -32,8 +30,6 @@ public record EventEnvelope( String eventType, int eventVersion, Instant occurredAt, - RegionCode sourceRegion, - RegionCode targetRegion, String tenantId, String aggregateType, String aggregateId, diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java index 6d09aae10..f0ca66922 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java @@ -37,12 +37,6 @@ public final class EventEnvelopeValidator { if (envelope.occurredAt() == null) { errors.add("occurredAt 不能为空"); } - if (envelope.sourceRegion() == null) { - errors.add("sourceRegion 不能为空"); - } - if (envelope.targetRegion() == null) { - errors.add("targetRegion 不能为空"); - } requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH); if (envelope.aggregateType() == null || !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) { diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java b/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java deleted file mode 100644 index f70c608c2..000000000 --- a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.baiyue.common.event; - -/** - * 事件来源与目标部署区域。 - * - *

区域表示独立业务单元,不表示用户当前所在地。

- */ -public enum RegionCode { - /** 国内业务单元。 */ - CN, - /** 海外业务单元。 */ - OVERSEAS -} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java index e21ce9ed2..69ac6b3b2 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -8,8 +8,7 @@ import static org.assertj.core.api.Assertions.*; class DeliveryAckTest { private EventEnvelope event(String type) { - return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), - RegionCode.CN, RegionCode.OVERSEAS, "0", "TEST", "1", 1, null, + return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), "0", "TEST", "1", 1, null, null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64)); } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java index 569608b99..b2d115827 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java @@ -13,7 +13,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("跨区域事件信封契约") +@DisplayName("跨服务事件信封契约") class EventEnvelopeTest { private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @@ -26,6 +26,8 @@ class EventEnvelopeTest { EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class); assertThat(restored).isEqualTo(source); + assertThat(objectMapper.readTree(json).has("sourceRegion")).isFalse(); + assertThat(objectMapper.readTree(json).has("targetRegion")).isFalse(); assertThat(objectMapper.readTree(json).path("eventType").asText()) .isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED); } @@ -40,7 +42,7 @@ class EventEnvelopeTest { @Test void validatorReturnsAllBasicErrorsAtOnce() { EventEnvelope envelope = new EventEnvelope( - "bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", + "bad-id", "bad_type", 0, null, " ", "bad-type", " ", -1, 0L, " ", " ", "bad-cause", null, "bad-hash"); List errors = EventEnvelopeValidator.validate(envelope); @@ -90,8 +92,6 @@ class EventEnvelopeTest { EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, 1, Instant.parse("2026-07-29T08:30:00Z"), - RegionCode.CN, - RegionCode.OVERSEAS, "20001", "FIRST_LEG_SHIPMENT", "90001", diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index db8bb210f..cf49042bd 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -13,7 +13,7 @@ | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | -| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java`;`HttpSessionStateVerifier.java` 每请求调用 Auth 权威状态校验,超时/错误失败关闭 | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | @@ -24,5 +24,6 @@ - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 - `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 -- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 +- Access 先校验 Redis 会话身份,再通过 `baiyue.security.session-validation-uri` 在线核对 Auth/IAM 持久化状态;禁止正结果缓存、失败降级或请求头信任。内部调用只带服务令牌和 jwtId/userId,不透传 Bearer;4 秒总超时、1 秒连接超时、不跟随重定向。 +- 不降低服务端客户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index 25fe60b58..e6eef8e8d 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -50,13 +50,16 @@ public class BaiyueSecurityAutoConfiguration { org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, - @Value("${baiyue.security.session-mode:redis}") String sessionMode) { + @Value("${baiyue.security.session-mode:redis}") String sessionMode, + @Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") String validationUri, + @Value("${baiyue.security.internal-service-token}") String internalToken) { if (!"redis".equalsIgnoreCase(sessionMode)) { throw new IllegalStateException("Only shared Redis access sessions are supported"); } StringRedisTemplate template = redis.getIfAvailable(); if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate"); - AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper); + AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper, + new HttpSessionStateVerifier(validationUri, internalToken, objectMapper)); return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } diff --git a/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java b/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java new file mode 100644 index 000000000..c9f4f864f --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java @@ -0,0 +1,44 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +/** Bounded, non-redirecting internal call. Never forwards an end-user Authorization header. */ +public final class HttpSessionStateVerifier implements SessionStateVerifier { + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + private final URI uri; + private final String token; + private final ObjectMapper mapper; + + public HttpSessionStateVerifier(String uri, String token, ObjectMapper mapper) { + this.uri = URI.create(uri); + this.token = token; + this.mapper = mapper; + } + + @Override public void verify(String jwtId, Long userId) { + try { + var request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(4)) + .header("Content-Type", "application/json").header("X-Internal-Service-Token", token) + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes( + Map.of("jwtId", jwtId, "userId", userId)))).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + var body = mapper.readTree(response.body()); + if (response.statusCode() != 200 || body == null || !body.path("code").isTextual() || !"200".equals(body.path("code").asText()) + || !body.path("data").isBoolean() || !body.path("data").booleanValue()) { + throw new IllegalArgumentException("Authorization state is revoked or unavailable"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalArgumentException("Authorization state validation interrupted", failure); + } catch (Exception failure) { + throw new IllegalArgumentException("Authorization state is revoked or unavailable", failure); + } + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java index 394cecb5d..f82791313 100644 --- a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java @@ -12,15 +12,14 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio private final String keyPrefix; private final ObjectMapper objectMapper; - public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { - this(redisTemplate, keyPrefix, new ObjectMapper()); - } + private final SessionStateVerifier stateVerifier; public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, SessionStateVerifier stateVerifier) { this.redisTemplate = redisTemplate; this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); this.objectMapper = objectMapper; + this.stateVerifier = java.util.Objects.requireNonNull(stateVerifier); } @Override @@ -39,6 +38,7 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio if (userId == null || !userId.equals(session.userId())) { throw new IllegalArgumentException("Access token session identity mismatch"); } + stateVerifier.verify(jwtId, userId); return session; } catch (org.springframework.dao.DataAccessException exception) { throw new IllegalArgumentException("Token session store is unavailable", exception); diff --git a/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java b/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java new file mode 100644 index 000000000..b6f4f8823 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java @@ -0,0 +1,7 @@ +package com.baiyue.common.security; + +/** Verifies persisted authorization state, without positive caching. */ +@FunctionalInterface +public interface SessionStateVerifier { + void verify(String jwtId, Long userId); +} diff --git a/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java b/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java new file mode 100644 index 000000000..52bef14cb --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java @@ -0,0 +1,45 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class HttpSessionStateVerifierTest { + @Test void acceptsOnlyExplicitSuccessAndNeverForwardsBearer() throws Exception { + var body = new AtomicReference<>("{\"code\":\"200\",\"data\":true}"); + var auth = new AtomicReference(); + var internal = new AtomicReference(); + var input = new AtomicReference(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/validate", exchange -> { + auth.set(exchange.getRequestHeaders().getFirst("Authorization")); + internal.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); + input.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + try { + var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + server.getAddress().getPort() + "/validate", "internal-only", new ObjectMapper()); + verifier.verify("session", 42L); + assertThat(auth.get()).isNull(); assertThat(internal.get()).isEqualTo("internal-only"); + assertThat(new ObjectMapper().readTree(input.get()).path("userId").longValue()).isEqualTo(42); + for (String rejected : new String[]{"{}", "{\"code\":200,\"data\":true}", "{\"code\":\"200\",\"data\":false}", + "{\"code\":\"A0403\",\"data\":true}", "{\"code\":\"200\",\"data\":\"true\"}", "not-json"}) { + body.set(rejected); + assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class); + } + } finally { server.stop(0); } + } + + @Test void unavailableAuthorityFailsClosed() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = server.getAddress().getPort(); server.start(); server.stop(0); + var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + port + "/validate", "internal", new ObjectMapper()); + assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 307a44d56..8e94d2033 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -56,7 +56,7 @@ class JwtAuthenticationFilterTest { .thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}"); JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - new RedisAccessTokenSessionValidator(redis, "baiyue:auth")); + new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), org.mockito.Mockito.mock(SessionStateVerifier.class))); MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/api/platform/v1/last-leg/orders"); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java index 1f75a9232..618e0c2a1 100644 --- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -24,7 +24,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}"); - assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))).doesNotThrowAnyException(); } @@ -35,7 +35,7 @@ class RedisAccessTokenSessionValidatorTest { when(redis.opsForValue()).thenReturn(values); when(values.get("baiyue:auth:access:jti-1")).thenReturn(null); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("revoked or expired"); @@ -49,7 +49,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenThrow(new RedisConnectionFailureException("offline")); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("unavailable"); @@ -63,7 +63,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}"); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("identity mismatch"); @@ -76,7 +76,7 @@ class RedisAccessTokenSessionValidatorTest { when(redis.opsForValue()).thenReturn(values); when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json"); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Invalid access token session"); diff --git a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java index 4280bbe8e..e744ca648 100644 --- a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java @@ -15,7 +15,7 @@ class UnifiedSessionModeTest { for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) { assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - redis, new ObjectMapper(), "baiyue:auth", mode)) + redis, new ObjectMapper(), "baiyue:auth", mode, "http://auth:9100/internal/auth/accounts/sessions/validate", "test-token")) .isInstanceOf(IllegalStateException.class); } } diff --git a/common-web/pom.xml b/common-web/pom.xml index ee8c7876b..47be8e5ac 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -14,6 +14,8 @@ baiyue-common-web + com.baiyue.sharedcommon-event-contract${project.version} + com.h2databaseh2test com.baiyue.shared baiyue-common-core diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java index 25768312a..39749ef1b 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java @@ -3,6 +3,13 @@ package com.baiyue.common.web; import feign.Request; import feign.Capability; import feign.Retryer; +import feign.codec.Decoder; +import feign.codec.DecodeException; +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.ParameterizedType; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import java.util.concurrent.TimeUnit; @@ -22,6 +29,9 @@ public class EventDeliveryFeignConfiguration { } /** Applied at Feign build time after global/client properties, without changing other clients. */ public static final class DeliveryTransportCapability implements Capability { + private static final ObjectMapper RESPONSE_JSON = new ObjectMapper() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private final Request.Options options; public DeliveryTransportCapability(Request.Options options) { this.options = options; } @Override public Request.Options enrich(Request.Options ignored) { return options; } @@ -29,5 +39,26 @@ public class EventDeliveryFeignConfiguration { // The persisted Outbox owns the retry budget. return Retryer.NEVER_RETRY; } + @Override public Decoder enrich(Decoder decoder) { + return (response, type) -> { + if (type instanceof ParameterizedType parameterized && parameterized.getRawType() == ApiResponse.class) { + // Jackson normally coerces a numeric 200 into String. Inspect the wire value first. + if (response.body() == null) throw new DecodeException(response.status(), "Missing event response", response.request()); + byte[] bytes; + try (var stream = response.body().asInputStream()) { bytes = stream.readNBytes(65537); } + if (bytes.length > 65536) throw new DecodeException(response.status(), "Event response exceeds limit", response.request()); + try { + var root = RESPONSE_JSON.readTree(bytes); + if (root == null || !root.isObject() || !root.path("code").isTextual()) { + throw new IllegalArgumentException(); + } + } catch (Exception invalid) { + throw new DecodeException(response.status(), "Invalid event response contract", response.request()); + } + return decoder.decode(response.toBuilder().body(bytes).build(), type); + } + return decoder.decode(response, type); + }; + } } } diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java new file mode 100644 index 000000000..188b63a79 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java @@ -0,0 +1,78 @@ +package com.baiyue.common.web; + +import io.micrometer.core.instrument.Gauge; +import com.baiyue.common.event.EventDeliveryRoute; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Fixed labels only. Scheduled samplers query the local schema; scrape callbacks only read memory. */ +public final class EventDeliveryMetrics { + public enum Stream { + FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING", + List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")), + LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING", + List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested")), + FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")), + FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted")); + public final String service, table, status, processing; + public final List types; + Stream(String service, String table, String status, String processing, List types) { + this.service = service; this.table = table; this.status = status; this.processing = processing; this.types = types; + } + public String type(String candidate) { return candidate != null && types.contains(candidate) ? candidate : "UNKNOWN"; } + public String target(String type) { + if (!types.contains(type)) return "UNRESOLVED"; // Audit only; never a transport fallback. + var route = EventDeliveryRoute.forEventType(type); + return route.serviceName() + route.path(); + } + } + private record Snapshot(double pending, double dead, double oldestSeconds, double sampledAt, double success) { } + private static final class Cell { volatile Snapshot value = new Snapshot(Double.NaN, Double.NaN, Double.NaN, 0, 0); } + private final JdbcTemplate jdbc; + private final MeterRegistry registry; + private final Map cells = new ConcurrentHashMap<>(); + public EventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { this.jdbc = jdbc; this.registry = registry; } + + public void sample(Stream stream, LocalDateTime now) { + for (String type : stream.types) { + Cell cell = cells.computeIfAbsent(stream.name() + ":" + type, ignored -> register(stream, type)); + try { + String filter = stream == Stream.FINANCE ? "" : " WHERE event_type=?"; + String sql = "SELECT SUM(CASE WHEN " + stream.status + " IN ('PENDING','" + stream.processing + + "') THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN " + stream.status + + "='DEAD' THEN 1 ELSE 0 END) AS dead_count, MIN(CASE WHEN " + stream.status + + " IN ('PENDING','" + stream.processing + "') THEN created_at END) AS oldest FROM " + stream.table + filter; + Map result = stream == Stream.FINANCE ? jdbc.queryForMap(sql) : jdbc.queryForMap(sql, type); + LocalDateTime oldest = databaseTime(result.get("oldest")); + cell.value = new Snapshot(count(result.get("pending_count")), count(result.get("dead_count")), + oldest == null ? 0 : Math.max(0, Duration.between(oldest, now).toMillis() / 1000.0), + System.currentTimeMillis() / 1000.0, 1); + } catch (RuntimeException failure) { + Snapshot old = cell.value; + cell.value = new Snapshot(old.pending, old.dead, old.oldestSeconds, old.sampledAt, 0); + } + } + } + private Cell register(Stream stream, String type) { + Cell cell = new Cell(); + Gauge.builder("baiyue.event.delivery.pending", cell, c -> c.value.pending).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.dead", cell, c -> c.value.dead).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.oldest.pending.seconds", cell, c -> c.value.oldestSeconds).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.sample.timestamp", cell, c -> c.value.sampledAt).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.sample.success", cell, c -> c.value.success).tags("service", stream.service, "event_type", type).register(registry); + return cell; + } + private static LocalDateTime databaseTime(Object value) { + if (value == null) return null; + if (value instanceof LocalDateTime local) return local; + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + throw new IllegalArgumentException("Unsupported Outbox timestamp type"); + } + private static double count(Object value) { return value == null ? 0 : ((Number) value).doubleValue(); } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java new file mode 100644 index 000000000..dc184aef3 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java @@ -0,0 +1,29 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.common.event.EventIdentifiers; +import feign.FeignException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** Diagnostic identity and finite audit error codes; never serialize exception messages or payloads. */ +public final class EventDeliveryObservation { + private EventDeliveryObservation() { } + public static String eventId(ObjectMapper mapper, String json) { + try { + String id = mapper.readTree(json).path("eventId").asText(""); + return EventIdentifiers.isSupported(id) ? id : null; + } catch (Exception invalid) { return null; } // Invalid payloads are still audited by Outbox entry ID. + } + public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) { + if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) { + return "HTTP_" + http.status(); + } + } + if (!requestStarted) return "INVALID_EVENT"; + return permanent ? "PERMANENT_FAILURE" : "DELIVERY_FAILURE"; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java new file mode 100644 index 000000000..efa4c05f8 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +@AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}) +@ConditionalOnBean(EventDeliveryProducer.class) +public class EventDeliveryObservationAutoConfiguration { + @Bean + EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { + return new EventDeliveryJournal(jdbc, manager); + } + @Bean + EventDeliveryMetrics eventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { + return new EventDeliveryMetrics(jdbc, registry); + } +} diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 3fb8cf813..90fd2005c 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,3 +3,4 @@ com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration com.baiyue.common.web.RestClientTracePropagationAutoConfiguration com.baiyue.common.web.BaiyueSchedulingAutoConfiguration +com.baiyue.common.web.EventDeliveryObservationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java index dd94c9ff5..962c11d4b 100644 --- a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java @@ -11,6 +11,31 @@ import static org.assertj.core.api.Assertions.*; class EventDeliveryFeignConfigurationTest { interface Endpoint { @RequestLine("POST /events") String send(); } + interface TypedEndpoint { @RequestLine("POST /events") com.baiyue.common.core.api.ApiResponse> send(); } + + @Test void wireCodeMustBeAStringAndAmbiguousJsonCannotBeAcknowledged() { + for (String body : List.of("{\"code\":200}", "{\"code\":true}", "{\"code\":\"200\",\"code\":\"500\"}", "{\"code\":\"200\"} {}", "{}")) { + var decoded = new AtomicInteger(); + TypedEndpoint endpoint = Feign.builder() + .decoder((response, type) -> { decoded.incrementAndGet(); return com.baiyue.common.core.api.ApiResponse.success(Map.of()); }) + .addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build()) + .target(TypedEndpoint.class, "http://event-consumer.invalid"); + assertThatThrownBy(endpoint::send).isInstanceOf(feign.codec.DecodeException.class); + assertThat(decoded.get()).isZero(); + } + } + + @Test void validStringCodePreservesBodyForTheConfiguredDecoder() { + String body = "{\"code\":\"200\",\"data\":{\"outcome\":\"APPLIED\"}}"; + TypedEndpoint endpoint = Feign.builder().decoder((response, type) -> { + assertThat(new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)).isEqualTo(body); + return com.baiyue.common.core.api.ApiResponse.success(Map.of("outcome", "APPLIED")); + }).addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build()) + .target(TypedEndpoint.class, "http://event-consumer.invalid"); + assertThat(endpoint.send().data()).containsEntry("outcome", "APPLIED"); + } @Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() { var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60); diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java new file mode 100644 index 000000000..6a9e9da25 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java @@ -0,0 +1,71 @@ +package com.baiyue.common.web; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.time.LocalDateTime; +import java.sql.Timestamp; +import java.util.Map; +import java.util.List; +import static org.mockito.ArgumentMatchers.*; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventDeliveryMetricsTest { + @Test void samplesBoundedTypesAndPreservesPriorValuesWhenDatabaseFailsWithoutQueryingDuringScrapes() { + var jdbc = spy(new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""))); + jdbc.execute("CREATE TABLE ll_event_outbox(event_type VARCHAR(200),status VARCHAR(32),created_at TIMESTAMP)"); + var now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PENDING',?)", now.minusSeconds(60)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PROCESSING',?)", now.minusSeconds(30)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','DEAD',?)", now.minusSeconds(120)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('user-controlled-unknown-type','PENDING',?)", now.minusYears(1)); + var registry = new SimpleMeterRegistry(); + var metrics = new EventDeliveryMetrics(jdbc, registry); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2); + assertThat(registry.get("baiyue.event.delivery.dead").gauge().value()).isEqualTo(1); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isBetween(59.99, 60.01); + double sampledAt = registry.get("baiyue.event.delivery.sample.timestamp").gauge().value(); + jdbc.execute("DROP TABLE ll_event_outbox"); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now.plusMinutes(1)); + clearInvocations(jdbc); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2); + assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isEqualTo(sampledAt); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + registry.getMeters().forEach(meter -> { + assertThat(meter.getId().getTag("event_type")).isEqualTo("BusinessFeeSourceChanged"); + meter.measure().forEach(measurement -> measurement.getValue()); + }); + verifyNoInteractions(jdbc); + } + @Test void supportsBothMysqlLocalDateTimeAndJdbcTimestampAndRejectsUnknownTypes() { + var jdbc = mock(JdbcTemplate.class); + var now = LocalDateTime.of(2026, 9, 29, 12, 0); + var registry = new SimpleMeterRegistry(); + var metrics = new EventDeliveryMetrics(jdbc, registry); + for (Object oldest : List.of(now.minusSeconds(45), Timestamp.valueOf(now.minusSeconds(45)))) { + Map row = Map.of("pending_count", 1L, "dead_count", 0L, "oldest", oldest); + when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))).thenReturn(row); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isEqualTo(1); + } + when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))) + .thenReturn(Map.of("pending_count", 0L, "dead_count", 0L, "oldest", "not a JDBC time")); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(1); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + } + @Test void initialFailureIsUnknownRatherThanFalseZero() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID(), "sa", "")); + var registry = new SimpleMeterRegistry(); + new EventDeliveryMetrics(jdbc, registry).sample(EventDeliveryMetrics.Stream.FINANCE, LocalDateTime.now()); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isNaN(); + assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isZero(); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java new file mode 100644 index 000000000..19a1a9674 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java @@ -0,0 +1,53 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryObservationAutoConfigurationTest { + @Test void configuresMandatoryAuditAndMetricsForServiceLocalJdbc() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:auto", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> { + assertThat(context).hasSingleBean(EventDeliveryJournal.class); + assertThat(context).hasSingleBean(EventDeliveryMetrics.class); + }); + } + @Test void otherJdbcServicesDoNotAcquireTransactionOrMetricsRequirements() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:other", "sa", ""))) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(EventDeliveryJournal.class); + assertThat(context).doesNotHaveBean(EventDeliveryMetrics.class); + }); + } + @Test void producerWithoutMeterRegistryFailsInsteadOfSilentlyDisablingObservation() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noRegistry", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .run(context -> assertThat(context).hasFailed()); + } + @Test void missingTransactionManagerFailsRatherThanInstallingNonTransactionalFallback() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:missing", "sa", ""))) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java new file mode 100644 index 000000000..83f6a9ef8 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java @@ -0,0 +1,22 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryObservationTest { + @Test void auditIdentitySupportsTheFormalUuidAndUlidContractWithoutSavingMalformedPayload() { + var mapper = new ObjectMapper(); + for (String id : new String[]{"00000000-0000-4000-8000-000000000019", "01ARZ3NDEKTSV4RRFFQ69G5FAV"}) { + assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"" + id + "\",\"payload\":\"secret\"}")).isEqualTo(id); + } + assertThat(EventDeliveryObservation.eventId(mapper, "bad secret JSON")).isNull(); + assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"Bearer secret\"}")).isNull(); + } + @Test void arbitraryExceptionsAndRemoteMessagesCannotLeakIntoAttemptAudit() { + var failure = new IllegalStateException("payload={secret} Authorization=secret"); + assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("DELIVERY_FAILURE"); + assertThat(EventDeliveryObservation.errorCode(failure, true, true)).isEqualTo("PERMANENT_FAILURE"); + assertThat(EventDeliveryObservation.errorCode(failure, true, false)).isEqualTo("INVALID_EVENT"); + } +} diff --git a/config/nacos/cn/README.md b/config/nacos/cn/README.md deleted file mode 100644 index 704d7ffbf..000000000 --- a/config/nacos/cn/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# 国内 Nacos 配置 - -将本目录文件按原文件名作为 Data ID 发布到 `prod-cn` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `cn` Profile。 - -尾程配置不进入本仓库;头程和尾程只通过跨区域事件桥协作。 diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml deleted file mode 100644 index 891768268..000000000 --- a/config/nacos/cn/auth-service.yml +++ /dev/null @@ -1,17 +0,0 @@ -spring: - datasource: - url: ${AUTH_DB_URL} - username: ${AUTH_DB_USERNAME} - password: ${AUTH_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - security: - access-token-ttl: ${ACCESS_TOKEN_TTL:24m} - refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:true} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml deleted file mode 100644 index 27a06967c..000000000 --- a/config/nacos/cn/event-bridge-service.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - datasource: - url: ${EVENT_BRIDGE_DB_URL} - username: ${EVENT_BRIDGE_DB_USERNAME} - password: ${EVENT_BRIDGE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - region: ${REGION_CODE:CN} - deployment-cell: ${DEPLOYMENT_CELL:prod-cn} - data-residency: ${DATA_RESIDENCY:CN} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} - firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} - finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events} - finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events} diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml deleted file mode 100644 index aa39d45b0..000000000 --- a/config/nacos/cn/finance-service.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - url: ${FINANCE_DB_URL} - username: ${FINANCE_DB_USERNAME} - password: ${FINANCE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} -baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/README.md b/config/nacos/local/README.md deleted file mode 100644 index ce6d3e445..000000000 --- a/config/nacos/local/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# 头程本地 Nacos 配置 - -本目录配置用于本机开发环境,发布目标为 `public` 命名空间和 `BAIYUE` 分组。 - -配置通过环境变量保留覆盖能力,并提供当前本机可直接启动的默认地址。生产环境必须使用 `config/nacos/cn`,不得使用本目录中的本地密码和本地密钥。 - -发布命令: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1 -``` - -也可以使用统一入口;`local` 会被强制限制在 `public` namespace: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local -``` - -配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。 - -本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括 -`gateway-overseas.yml`、`lastleg-service.yml` 和 -`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机 -Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。 - -## MyBatis SQL 日志 - -本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。 - -可通过环境变量统一调整: - -```powershell -$env:MYBATIS_SQL_LOG_LEVEL = "INFO" # 关闭 SQL 明细 -$env:MYBATIS_SQL_LOG_LEVEL = "DEBUG" # 打开 SQL 明细 -``` - -修改后重新发布本地 Nacos 配置并重启相关服务。SQL 参数可能包含业务数据,该设置只用于本地开发,不得复制到测试或生产环境。 diff --git a/config/nacos/local/audit-service.yml b/config/nacos/local/audit-service.yml deleted file mode 100644 index f1d414ec6..000000000 --- a/config/nacos/local/audit-service.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} diff --git a/config/nacos/local/auth-service.yml b/config/nacos/local/auth-service.yml deleted file mode 100644 index 64d7b2be0..000000000 --- a/config/nacos/local/auth-service.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} -password: ${AUTH_DB_PASSWORD:123456} - -baiyue: - security: - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:true} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml deleted file mode 100644 index 5acc5cbe2..000000000 --- a/config/nacos/local/baiyue-common.yml +++ /dev/null @@ -1,73 +0,0 @@ -baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:local-cn} - data-residency: ${DATA_RESIDENCY:CN} - security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - issuer: ${JWT_ISSUER:auth-service} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} - warehouse: - public-tenant-id: 0 - openapi.enabled: ${SWAGGER_ENABLED:true} - audit: - enabled: true - endpoint: ${AUDIT_SERVICE_URL:http://127.0.0.1:9700}/internal/audit/events - outbox: - enabled: ${AUDIT_OUTBOX_ENABLED:false} - publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false} - batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50} - lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60} - max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5} - max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2} - compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} - retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} - rocketmq: - topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 - send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} -spring: - jackson: - default-property-inclusion: non_null - deserialization.fail-on-unknown-properties: false - serialization.write-dates-as-timestamps: false - data: - redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - timeout: ${REDIS_TIMEOUT:3s} - cloud: - nacos.discovery: - # Local services must stay reachable through the gateway when VPN adapters change priority. - ip: ${NACOS_DISCOVERY_IP:127.0.0.1} - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - openfeign: - circuitbreaker.enabled: true - client.config.default: - connect-timeout: 3000 - read-timeout: 5000 -server: - shutdown: graceful - max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} -management: - endpoints.web.exposure.include: health,info,prometheus - endpoint.health: - probes.enabled: true - show-details: always -mybatis-plus: - configuration: - map-underscore-to-camel-case: true - call-setters-on-nulls: true - global-config: - banner: false - db-config: - id-type: assign_id - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} diff --git a/config/nacos/local/event-bridge-service.yml b/config/nacos/local/event-bridge-service.yml deleted file mode 100644 index e70f98f7f..000000000 --- a/config/nacos/local/event-bridge-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} - username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:123456} -baiyue: - region: ${REGION_CODE:CN} - event-bridge: - accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true} - deployment-cell: ${DEPLOYMENT_CELL:local-cn} - data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml deleted file mode 100644 index 31fcdb590..000000000 --- a/config/nacos/local/file-service.yml +++ /dev/null @@ -1,123 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} -baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} -baiyue: - file: - export: - definitions: - CUSTOMER_LIST: - provider-service: iam-service - provider-path: /internal/exports/v1/CUSTOMER_LIST/batches - required-permission: platform:customer:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - - DOMESTIC_WAREHOUSE_ORDER_LIST: - provider-service: order-service - provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches - required-permission: platform:order:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 200 - - LOADING_LIST_BUY_DECLARATION_ITEMS: - provider-service: firstleg-service - provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches - required-permission: platform:firstleg:customs:manage - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - - FINANCE_CUSTOMER_ACCOUNT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches - required-permission: platform:finance:customer-account:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_CUSTOMER_ACCOUNT_LEDGER: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches - required-permission: platform:finance:customer-account:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_FEE_SOURCE_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches - required-permission: platform:finance:customer-arap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECONCILIATION_STATEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches - required-permission: platform:finance:customer-statement:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_SETTLEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches - required-permission: platform:finance:settlement-ar:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches - required-permission: platform:finance:settlement-ar:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_PAYABLE_SETTLEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches - required-permission: platform:finance:settlement-ap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_PAYABLE_SETTLEMENT_DETAIL: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches - required-permission: platform:finance:settlement-ap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_STATISTICS: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches - required-permission: platform:finance:receivable-statistics:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 -baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} -baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml deleted file mode 100644 index 6269c2f7a..000000000 --- a/config/nacos/local/finance-service.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FINANCE_DB_USERNAME:root} - password: ${FINANCE_DB_PASSWORD:123456} -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg} -baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml deleted file mode 100644 index de2ce6708..000000000 --- a/config/nacos/local/firstleg-service.yml +++ /dev/null @@ -1,69 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FIRSTLEG_DB_USERNAME:root} - password: ${FIRSTLEG_DB_PASSWORD:123456} -spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} -spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB} -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.clients.order.url: ${ORDER_SERVICE_URL:} -baiyue.clients.file.url: ${FILE_SERVICE_URL:} -baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} -baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} -baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} -baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} -baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} -baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} -baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} -baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} -baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} -firstleg: - lingxing: - auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} - base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} - login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} - provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} - # Firstleg and the isolated Lingxing browser run on the same Docker network. - # Keep the environment override for deployments that use another endpoint. - browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} - username: ${LINGXING_USERNAME:} - password: ${LINGXING_PASSWORD:} - browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} - token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} - token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} - track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:} - request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s} - warehouses: [US01, US02] - inbound: - allow-rejected-measurement: false - default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} - reservation-ttl: ${PALLET_RESERVATION_TTL:30m} - warehouse-resource-tenant-id: 0 - measurement-openapi: - enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} - # 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。 - signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false} - max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} - nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} - apps: - # 墨家使用平台级 AppKey;tenant-id 固定为 0,按全局箱码解析真实客户。 - # warehouse-bindings 的键必须与设备上传的 machineNo 完全一致。 - mojia: - enabled: ${MOJIA_MEASUREMENT_ENABLED:false} - secret: ${MOJIA_MEASUREMENT_SECRET:} - tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} - subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} - warehouse-bindings: - mojia-yiwu-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} - # 历史通用应用配置,保留兼容;新接入请使用 mojia。 - device-provider: - enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} - secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} - tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0} - subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} - warehouse-bindings: - CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} diff --git a/config/nacos/local/gateway-overseas.yml b/config/nacos/local/gateway-overseas.yml deleted file mode 100644 index ea2ef8cb5..000000000 --- a/config/nacos/local/gateway-overseas.yml +++ /dev/null @@ -1,16 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} -baiyue: - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/local/gateway.yml b/config/nacos/local/gateway.yml deleted file mode 100644 index c31d6422d..000000000 --- a/config/nacos/local/gateway.yml +++ /dev/null @@ -1,11 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} -baiyue: - cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} - allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,PATCH,DELETE,OPTIONS} - allowed-headers: ${CORS_ALLOWED_HEADERS:*} - exposed-headers: ${CORS_EXPOSED_HEADERS:X-Request-Id,X-Trace-Id} - allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} - max-age: ${CORS_MAX_AGE:3600} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml deleted file mode 100644 index a1a4c5f90..000000000 --- a/config/nacos/local/iam-service.yml +++ /dev/null @@ -1,16 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${IAM_DB_USERNAME:root} - password: ${IAM_DB_PASSWORD:123456} -baiyue: - security-event: - # 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。 - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} - notification: - scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} - dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} - mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml deleted file mode 100644 index b02d9d323..000000000 --- a/config/nacos/local/integration-service.yml +++ /dev/null @@ -1,53 +0,0 @@ -spring: - servlet: - multipart: - max-file-size: 5MB - max-request-size: 6MB - -baiyue: - integration: - baidu-ocr: - enabled: ${BAIDU_OCR_ENABLED:false} - input-root: ${WEIGHT_OCR_INPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/input} - output-root: ${WEIGHT_OCR_OUTPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/output} - max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB} - max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000} - max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} - amazon-sp-api: - enabled: ${AMAZON_SP_API_ENABLED:false} - carrier-schedule: - matson: - enabled: ${MATSON_TRACKING_ENABLED:false} - page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} - query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} - connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} - hede: - enabled: ${HEDE_TRACKING_ENABLED:false} - query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} - connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} - sml: - enabled: ${SML_TRACKING_ENABLED:false} - query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} - connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} - zim: - enabled: ${ZIM_TRACKING_ENABLED:false} - authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} - tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} - client-id: ${ZIM_CLIENT_ID:} - client-secret: ${ZIM_CLIENT_SECRET:} - subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} - connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} - max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} - evergreen: - enabled: ${EMC_TRACKING_ENABLED:false} - query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} - connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml deleted file mode 100644 index 7b8f3bd01..000000000 --- a/config/nacos/local/lastleg-service.yml +++ /dev/null @@ -1,29 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${LASTLEG_DB_USERNAME:root} - password: ${LASTLEG_DB_PASSWORD:123456} -lastleg: - dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} - smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} -baiyue: - clients: - domestic-warehouse: - url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} - cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} - file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} - warehouse: - base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305} - firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - jwt-mode: jwks - session-mode: redis - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml deleted file mode 100644 index 16d034278..000000000 --- a/config/nacos/local/order-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${ORDER_DB_USERNAME:root} - password: ${ORDER_DB_PASSWORD:123456} -baiyue.clients: - warehouse.url: ${WAREHOUSE_SERVICE_URL:} - -baiyue.order: - # 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。 - inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO} diff --git a/config/nacos/local/print-service.yml b/config/nacos/local/print-service.yml deleted file mode 100644 index c3fc80ed8..000000000 --- a/config/nacos/local/print-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -print: - pdf: - max-pages: ${PRINT_PDF_MAX_PAGES:2000} - max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} - max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} - max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} - acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} - template-code: INBOUND_MARK_V1 - supported-width-mm: 100 - supported-height-mm: 100 - font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml deleted file mode 100644 index a3b8cf9ee..000000000 --- a/config/nacos/local/warehouse-overseas-service.yml +++ /dev/null @@ -1,19 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} -baiyue: - clients: - domestic-warehouse: - url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} - cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} - internal-service: - token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - security: - jwt-mode: jwks - session-mode: redis - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/warehouse-service.yml b/config/nacos/local/warehouse-service.yml deleted file mode 100644 index b0e8a969c..000000000 --- a/config/nacos/local/warehouse-service.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md deleted file mode 100644 index 71866504e..000000000 --- a/config/nacos/overseas/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# 海外 Nacos 配置 - -本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。 - -海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 - -发布示例: - -```powershell -# 开发环境 -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` - -Environment development -Region overseas -ServerAddr -Namespace - -# 测试环境 -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` - -Environment test -Region overseas -ServerAddr -Namespace -``` - -远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml deleted file mode 100644 index 1ac49f251..000000000 --- a/config/nacos/overseas/audit-service.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${AUDIT_DB_URL} - username: ${AUDIT_DB_USERNAME} - password: ${AUDIT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml deleted file mode 100644 index a5e5cb364..000000000 --- a/config/nacos/overseas/baiyue-common.yml +++ /dev/null @@ -1,67 +0,0 @@ -baiyue: - deployment: - region-code: ${REGION_CODE:OVERSEAS} - cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} - openapi.enabled: ${SWAGGER_ENABLED:false} - audit.enabled: true -spring: - jackson: - default-property-inclusion: non_null - deserialization: - fail-on-unknown-properties: false - serialization: - write-dates-as-timestamps: false - data: - redis: - host: ${REDIS_HOST:redis-overseas.internal} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - timeout: ${REDIS_TIMEOUT:3s} - cloud: - nacos: - discovery: - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-overseas} - openfeign: - circuitbreaker.enabled: true - client.config.default: - connect-timeout: 3000 - read-timeout: 5000 -server: - max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} - shutdown: graceful -management: - endpoints: - web: - exposure: - include: health,info,prometheus - endpoint: - health: - probes.enabled: true - show-details: never -mybatis-plus: - configuration: - map-underscore-to-camel-case: true - call-setters-on-nulls: true - global-config: - banner: false - db-config: - id-type: assign_id - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml deleted file mode 100644 index 00a8ac755..000000000 --- a/config/nacos/overseas/event-bridge-service.yml +++ /dev/null @@ -1,21 +0,0 @@ -spring: - datasource: - url: ${EVENT_BRIDGE_DB_URL} - username: ${EVENT_BRIDGE_DB_USERNAME} - password: ${EVENT_BRIDGE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - region: ${REGION_CODE:OVERSEAS} - deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} - fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} - batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} - max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} - lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} - initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} - max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} - connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} - request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} diff --git a/config/nacos/overseas/file-service.yml b/config/nacos/overseas/file-service.yml deleted file mode 100644 index 971474618..000000000 --- a/config/nacos/overseas/file-service.yml +++ /dev/null @@ -1,13 +0,0 @@ -spring: - datasource: - url: ${FILE_DB_URL} - username: ${FILE_DB_USERNAME} - password: ${FILE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -file: - storage: - provider: ${FILE_STORAGE_PROVIDER:s3} - endpoint: ${FILE_STORAGE_ENDPOINT:} - bucket: ${FILE_STORAGE_BUCKET:} - access-key: ${FILE_STORAGE_ACCESS_KEY:} - secret-key: ${FILE_STORAGE_SECRET_KEY:} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml deleted file mode 100644 index a9c1eadf2..000000000 --- a/config/nacos/overseas/gateway-overseas.yml +++ /dev/null @@ -1,9 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/integration-service.yml b/config/nacos/overseas/integration-service.yml deleted file mode 100644 index 25617fae1..000000000 --- a/config/nacos/overseas/integration-service.yml +++ /dev/null @@ -1,12 +0,0 @@ -baiyue: - integration: - baidu-ocr: - enabled: ${BAIDU_OCR_ENABLED:false} - amazon-sp-api: - enabled: ${AMAZON_SP_API_ENABLED:true} - client-id: ${AMAZON_SP_API_CLIENT_ID:} - client-secret: ${AMAZON_SP_API_CLIENT_SECRET:} - refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:} - aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} - aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} - region: ${AMAZON_SP_API_REGION:} diff --git a/config/nacos/overseas/print-service.yml b/config/nacos/overseas/print-service.yml deleted file mode 100644 index 613433e3f..000000000 --- a/config/nacos/overseas/print-service.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${PRINT_DB_URL} - username: ${PRINT_DB_USERNAME} - password: ${PRINT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/test/README.md b/config/nacos/test/README.md new file mode 100644 index 000000000..5680b52c3 --- /dev/null +++ b/config/nacos/test/README.md @@ -0,0 +1,9 @@ +# TEST 单机配置 + +唯一导入目录为本目录的 14 个 YAML:`baiyue-common.yml` 加 13 个服务 Data ID。Group 默认 `BAIYUE`,所有配置和发现共用实际 `NACOS_NAMESPACE` ID;不能填写命名空间显示名。先导入公共配置再导入服务配置。应用不再使用 optional import,缺失配置应阻断启动。 + +变量在服务进程内解析,导入文件不得替换成真实密钥。无密钥模板见 `../../../deploy/env/.env.test.example`。私钥仅传入 Auth;运维令牌仅传入 Firstleg、Finance、Lastleg。各业务数据库 Schema 独立。 + +离线预检:`./scripts/publish-nacos-config.ps1 -ServerAddr '' -Namespace '' -DryRun`(从后端根运行,不联网、不登录)。正式发布去掉 DryRun,会修改指定 Nacos,需授权;认证从进程环境 NACOS_USERNAME/NACOS_PASSWORD 取得。失败立即停止,已发布项不自动撤销,保存本轮配置版本并在停写窗口重新发布完整匹配集合。 + +切换顺序、发布与恢复约束见 `../../../deploy/TEST单机部署执行清单.md`;文件存在不代表环境已发布。 diff --git a/config/nacos/cn/audit-service.yml b/config/nacos/test/audit-service.yml similarity index 100% rename from config/nacos/cn/audit-service.yml rename to config/nacos/test/audit-service.yml diff --git a/config/nacos/test/auth-service.yml b/config/nacos/test/auth-service.yml new file mode 100644 index 000000000..10ae3fae1 --- /dev/null +++ b/config/nacos/test/auth-service.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: ${AUTH_DB_URL} + username: ${AUTH_DB_USERNAME} + password: ${AUTH_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + security: + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} + refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} +baiyue.security.jwt.rsa-jwk-set: ${JWT_RSA_JWK_SET} +baiyue.security.jwt.current-kid: ${JWT_CURRENT_KID} diff --git a/config/nacos/cn/baiyue-common.yml b/config/nacos/test/baiyue-common.yml similarity index 81% rename from config/nacos/cn/baiyue-common.yml rename to config/nacos/test/baiyue-common.yml index f7318ae43..db9463ead 100644 --- a/config/nacos/cn/baiyue-common.yml +++ b/config/nacos/test/baiyue-common.yml @@ -1,10 +1,9 @@ baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:prod-cn} - data-residency: ${DATA_RESIDENCY:CN} security: - jwt-secret: ${JWT_SECRET} + jwt-mode: jwks + session-mode: redis + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} issuer: ${JWT_ISSUER:auth-service} internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} @@ -24,7 +23,7 @@ baiyue: compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} rocketmq: - topic: ${MQ_TOPIC_PREFIX:}baiyue-audit-event-v1 + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} spring: jackson: @@ -35,7 +34,8 @@ spring: write-dates-as-timestamps: false data: redis: - host: ${REDIS_HOST:redis-cn.internal} + username: ${REDIS_USERNAME:default} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} @@ -44,7 +44,7 @@ spring: nacos: discovery: group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-cn} + namespace: ${NACOS_NAMESPACE} openfeign: circuitbreaker.enabled: true client.config.default: @@ -76,3 +76,5 @@ mybatis-plus: springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} + +baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate} diff --git a/config/nacos/cn/file-service.yml b/config/nacos/test/file-service.yml similarity index 100% rename from config/nacos/cn/file-service.yml rename to config/nacos/test/file-service.yml diff --git a/config/nacos/test/finance-service.yml b/config/nacos/test/finance-service.yml new file mode 100644 index 000000000..bbc4da782 --- /dev/null +++ b/config/nacos/test/finance-service.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: ${FINANCE_DB_URL} + username: ${FINANCE_DB_USERNAME} + password: ${FINANCE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} +baiyue.event-delivery.observation-enabled: true +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/test/firstleg-service.yml similarity index 87% rename from config/nacos/cn/firstleg-service.yml rename to config/nacos/test/firstleg-service.yml index 42b868723..ca18c236b 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/test/firstleg-service.yml @@ -29,11 +29,8 @@ baiyue: max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} firstleg: lingxing: # 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。 @@ -81,3 +78,12 @@ firstleg: subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} warehouse-bindings: CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} +baiyue.event-delivery.observation-enabled: true +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/gateway.yml b/config/nacos/test/gateway.yml similarity index 100% rename from config/nacos/cn/gateway.yml rename to config/nacos/test/gateway.yml diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/test/iam-service.yml similarity index 84% rename from config/nacos/cn/iam-service.yml rename to config/nacos/test/iam-service.yml index f735164e7..659d5044e 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/test/iam-service.yml @@ -5,8 +5,6 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/test/integration-service.yml similarity index 100% rename from config/nacos/cn/integration-service.yml rename to config/nacos/test/integration-service.yml diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/test/lastleg-service.yml similarity index 77% rename from config/nacos/overseas/lastleg-service.yml rename to config/nacos/test/lastleg-service.yml index c468507d4..b555e61c4 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/test/lastleg-service.yml @@ -45,13 +45,17 @@ baiyue: url: ${FILE_SERVICE_URL} domestic-file: url: ${FILE_SERVICE_URL} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} +baiyue.event-delivery.observation-enabled: true +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/order-service.yml b/config/nacos/test/order-service.yml similarity index 100% rename from config/nacos/cn/order-service.yml rename to config/nacos/test/order-service.yml diff --git a/config/nacos/cn/print-service.yml b/config/nacos/test/print-service.yml similarity index 100% rename from config/nacos/cn/print-service.yml rename to config/nacos/test/print-service.yml diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/test/warehouse-overseas-service.yml similarity index 100% rename from config/nacos/overseas/warehouse-overseas-service.yml rename to config/nacos/test/warehouse-overseas-service.yml diff --git a/config/nacos/cn/warehouse-service.yml b/config/nacos/test/warehouse-service.yml similarity index 100% rename from config/nacos/cn/warehouse-service.yml rename to config/nacos/test/warehouse-service.yml diff --git a/deploy/.env.overseas-auth.example b/deploy/.env.overseas-auth.example deleted file mode 100644 index 325a37c3d..000000000 --- a/deploy/.env.overseas-auth.example +++ /dev/null @@ -1,12 +0,0 @@ -# Auth signs short-lived overseas RS256 tokens with a private JWK Set. -OVERSEAS_JWT_ENABLED=true -OVERSEAS_JWT_ISSUER=auth-service -OVERSEAS_JWT_AUDIENCE=lastleg-api -OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid -OVERSEAS_ACCESS_TOKEN_TTL=15m -OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]} - -# Overseas services validate tokens against Auth's public JWKS endpoint. -JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json -JWT_ISSUER=auth-service -JWT_AUDIENCE=lastleg-api diff --git a/deploy/TEST单机部署执行清单.md b/deploy/TEST单机部署执行清单.md new file mode 100644 index 000000000..085b9618f --- /dev/null +++ b/deploy/TEST单机部署执行清单.md @@ -0,0 +1,42 @@ +# TEST 单机部署执行清单 + +本清单是本地可审核目标,尚未部署或完成真实环境验证。唯一入口:`docker-compose.yml`、`service-map.yml`、`env/.env.test.example` 与 `../config/nacos/test/`。正式规则见外层 `docs/测试环境单机部署与配置契约.md`、`docs/统一认证与服务通信契约.md`。 + +## 发布前清单 + +1. 记录13个服务镜像 tag/digest、后端和四端版本、SQL基线及14个Nacos配置版本。统一服务集合:Gateway/Auth/IAM/Order/Firstleg/Finance/Warehouse/Warehouse Overseas/Lastleg/File/Print/Audit/Integration。不存在单独海外Gateway或事件桥发布项。 +2. 人工核对实际Nacos Namespace ID、Group、主机容量、文件卷UID 10001读写权限、MySQL各Schema、Redis DB/会话前缀、现有MQ topic/group。环境模板的Schema名只是待核基线,不能据此重命名运行库。 +3. 所有容器共用 firstleg-test-network,并接外部 INFRA_NETWORK(默认 firstleg-test-infra-network)。中间件运行在宿主机时使用 host.docker.internal;运行在容器时用同网络服务名。禁止跨容器 localhost。Nacos应用名不等于Docker服务名,Auth HTTP地址使用 auth:9100。 +4. Gateway仅向宿主机127.0.0.1发布9000(可配置GATEWAY_PUBLISHED_PORT)。同网络Nginx转发 gateway:9000;其余业务仅内部expose。不可把内部API端口直接发布给用户。 +5. Auth私钥JWT_RSA_JWK_SET与JWT_CURRENT_KID由密钥凭据提供;所有服务共用JWT_JWKS_URI、Redis会话和内部服务令牌。EVENT_OPERATIONS_TOKEN与业务令牌分离,仅三家事件生产服务注入;为空禁用运维操作。禁止把真实env提交到仓库。 +6. 本地Node合同、后端编译/测试及前端门禁完成后才能安排部署。Jenkins总控 backend-java,下游 baiyue-firstleg-,构建节点java-builder,唯一部署节点java-test,凭据firstleg-test-env。旧dev/prod Job必须停用或改回其独立受维护版本,不得指向此TEST流水线;不对既有实际生产环境做改名、删除或部署。 + +## 停机切换 + +停止验收与业务写入并记录当前容器、网络、配置、卷、会话和消息对象。逐项取得部署、SQL和删除授权;本清单不授权执行。 + +先关闭旧写入、Relay和消费进程,再应用审核通过的服务SQL基线、统一Nacos和Compose配置。不执行整机清空Redis/MQ/文件,不删除业务Schema。业务MQ继续保留:文件导出、通知、审计启用参数按现有业务核实,test-前缀隔离;可靠审计可显式叠加docker-compose.audit.yml。文件卷持续挂载/data/files。 + +启动原则:中间件健康 → IAM/Auth和公共能力 → 业务消费者/生产者 → Gateway → 四端。Compose不创建循环depends_on;对每个服务以镜像HEALTHCHECK及真实消费者探针确认就绪后才开放写入。所有服务关闭自动SQL初始化,SQL必须单独审核执行。 + +执行入口deploy-built-service.sh仅接受test与13个保留服务,必须在目标服务器本机执行(TARGET_IS_LOCAL=true);缺失关键环境参数、没有HEALTHCHECK、异常退出、健康超时均失败。deploy-services.sh仅顺序调用此入口,任何失败停止后续服务。构建和部署仍由明确授权启动。 + +统一登录、撤销、客户隔离、仓绑定、文件和资源查询通过后,新造装柜→尾程、资金指令→结果回传数据。完成正式契约全链路与故障恢复矩阵前不得宣称环境跑通。 + +旧海外网关/两区桥退出目标清单不代表已停机。只有直接消费、可靠重试/DEAD治理、认证撤销及四端切换验证通过,且确认无旧调用,才能按明确对象清单退役旧容器、注册和专用配置。禁止提前停止现有线上桥。 + +## 失败恢复 + +架构/Schema不匹配时默认停写、保留证据,修复后发布完整匹配新版本。确需恢复旧测试工作时,由人工按记录整体恢复旧镜像、四端、Nacos/Compose和旧Schema基线;不混跑两套协议。 + +单服务自动回滚默认关闭。仅确认前后版本协议和Schema完全一致时,才能在单服务Jenkins勾选ALLOW_SAME_CONTRACT_ROLLBACK;失败只允许恢复同仓库同服务前一镜像,回滚成功仍将Job判为失败。首次部署、旧镜像不属于该服务或旧新镜像相同均拒绝回滚。 + +## 本地验证入口 + +`node --test scripts/java-topology-contract.test.mjs scripts/overseas-deployment-contract.test.mjs scripts/deploy-built-service-rollback.test.mjs scripts/finance-deployment-contract.test.mjs scripts/audit-deployment-contract.test.mjs` + +PowerShell Nacos发布DryRun只列待发布项,不联网;Shell/Groovy离线合同不能替代真实Jenkins语法校验、Docker Compose渲染、镜像启动或HTTP验收。 + +四端协作确认:Admin/Client各自使用firstleg-test-admin-web与firstleg-test-client-web Compose项目,共接external firstleg-test-network;默认API_UPSTREAM=http://gateway:9000,页面仅绑定127.0.0.1:9001/9002。发布必须提供CLIENT_WEB_URL与VITE_ADMIN_WEB_ORIGIN,不能猜测运行域名。两个PDA保留独立产物,共用统一入口。 + +SQL初始化脚本只接受两份显式传入、已审核的空库baseline路径;未设置ALLOW_TEST_SCHEMA_INITIALIZATION=true与明确TEST_MYSQL_CONTAINER时拒绝。它不检查或创建授权,不允许用于已有数据环境,不自动清库,第一份失败立即停止。两份基线的当前维护入口为deploy/sql/README.md;实际SQL执行仍由独立授权覆盖。 diff --git a/deploy/docker-compose.audit.yml b/deploy/docker-compose.audit.yml index b0d29c2e9..87893998e 100644 --- a/deploy/docker-compose.audit.yml +++ b/deploy/docker-compose.audit.yml @@ -4,40 +4,40 @@ services: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}auth-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}auth-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} iam: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}iam-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}iam-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} order: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}order-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}order-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} firstleg: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}firstleg-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}firstleg-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} warehouse: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}warehouse-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}warehouse-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} audit-service: environment: ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} AUDIT_MQ_CONSUMER_ENABLED: "true" AUDIT_DLQ_CONSUMER_ENABLED: "true" AUDIT_DLQ_COMPENSATION_ENABLED: "true" diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml deleted file mode 100644 index 3082caf78..000000000 --- a/deploy/docker-compose.overseas.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: firstleg-${DEPLOY_ENV:-test}-overseas - -x-overseas-common: &overseas-common - restart: "on-failure:3" - stop_grace_period: ${STOP_GRACE_PERIOD:-45s} - networks: [firstleg-overseas-network, infra-network] - extra_hosts: - - "host.docker.internal:host-gateway" - environment: &overseas-environment - BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling} - BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} - BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} - SERVER_SHUTDOWN: graceful - SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: overseas - NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} - NACOS_USERNAME: ${OVERSEAS_NACOS_USERNAME} - NACOS_PASSWORD: ${OVERSEAS_NACOS_PASSWORD} - NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} - REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} - REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379} - REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-} - REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0} - INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} - JWT_ISSUER: ${JWT_ISSUER} - JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} - JWT_JWK_SET_URI: ${JWT_JWK_SET_URI} - REGION_CODE: OVERSEAS - DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} - DATA_RESIDENCY: OVERSEAS - SPRING_SQL_INIT_MODE: never - SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} - TZ: Asia/Shanghai - logging: - # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 - driver: ${DOCKER_LOG_DRIVER:-json-file} - options: - max-size: ${DOCKER_LOG_MAX_SIZE:-50m} - max-file: "${DOCKER_LOG_MAX_FILE:-3}" - -services: - gateway-overseas: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PUBLISHED_PORT:-${GATEWAY_OVERSEAS_PORT:-9300}}:9300"] - environment: - <<: *overseas-environment - SERVER_PORT: 9300 - SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} - CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} - CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} - AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} - PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} - LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} - - lastleg-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-lastleg${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PUBLISHED_PORT:-${LASTLEG_PORT:-9303}}:9303"] - environment: - <<: *overseas-environment - SERVER_PORT: 9303 - LASTLEG_DB_URL: ${LASTLEG_DB_URL} - LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} - LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} - FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} - DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} - FILE_SERVICE_URL: ${FILE_SERVICE_URL} - # Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。 - PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} - WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} - - warehouse-overseas-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PUBLISHED_PORT:-${WAREHOUSE_OVERSEAS_PORT:-9305}}:9305"] - environment: - <<: *overseas-environment - SERVER_PORT: 9305 - WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} - WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} - WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} - DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} - # 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。 - PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} - - event-bridge-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-${EVENT_BRIDGE_OVERSEAS_PORT:-9313}}:9312"] - environment: - <<: *overseas-environment - SERVER_PORT: 9312 - EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL} - EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} - EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} - EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} - networks: - firstleg-overseas-network: - infra-network: - aliases: [event-bridge-overseas] - -networks: - firstleg-overseas-network: - name: firstleg-${DEPLOY_ENV:-test}-overseas-network - infra-network: - external: true - name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml index 7775ff3b9..45a047a6e 100644 --- a/deploy/docker-compose.rocketmq.yml +++ b/deploy/docker-compose.rocketmq.yml @@ -1,4 +1,4 @@ -name: firstleg-${DEPLOY_ENV:-test}-rocketmq +name: firstleg-test-rocketmq x-bounded-logging: &bounded-logging driver: ${DOCKER_LOG_DRIVER:-json-file} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a4c7dbdac..472f1e6a3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -name: firstleg-${DEPLOY_ENV:-test} +name: firstleg-test x-common: &common restart: "on-failure:3" @@ -8,31 +8,36 @@ x-common: &common extra_hosts: - "host.docker.internal:host-gateway" environment: &common-environment - DEPLOY_ENV: ${DEPLOY_ENV:-test} - BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling} + DEPLOY_ENV: test BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} SERVER_SHUTDOWN: graceful SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn} + SPRING_PROFILES_ACTIVE: test NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_USERNAME: ${NACOS_USERNAME} NACOS_PASSWORD: ${NACOS_PASSWORD} NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${NACOS_NAMESPACE:-public} + NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual TEST namespace ID required} REDIS_HOST: ${REDIS_HOST} REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_USERNAME: ${REDIS_USERNAME:-default} + SPRING_DATA_REDIS_USERNAME: ${REDIS_USERNAME:-default} REDIS_DATABASE: ${REDIS_DATABASE:-0} - JWT_SECRET: ${JWT_SECRET} + AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate + JWT_ISSUER: ${JWT_ISSUER:-auth-service} + JWT_AUDIENCE: ${JWT_AUDIENCE:-lastleg-api} + JWT_JWKS_URI: http://auth:9100/api/auth/.well-known/jwks.json + REDIS_TOKEN_KEY_PREFIX: ${REDIS_TOKEN_KEY_PREFIX:-baiyue:auth} + SPRING_SQL_INIT_MODE: never + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER} + MQ_TOPIC_PREFIX: test- INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} - BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events} + BAIYUE_AUDIT_ENDPOINT: http://audit-service:9700/internal/audit/events SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false} AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false} - REGION_CODE: ${REGION_CODE:-CN} - DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} - DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} TZ: Asia/Shanghai logging: # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 @@ -45,8 +50,8 @@ services: gateway: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-gateway${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] + container_name: firstleg-test-gateway + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] environment: <<: *common-environment SERVER_PORT: 9000 @@ -57,46 +62,53 @@ services: auth: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-auth${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PUBLISHED_PORT:-${AUTH_PORT:-9100}}:9100"] + container_name: firstleg-test-auth + expose: ["9100"] environment: <<: *common-environment SERVER_PORT: 9100 + JWT_RSA_JWK_SET: ${JWT_RSA_JWK_SET} + JWT_CURRENT_KID: ${JWT_CURRENT_KID} AUTH_DB_URL: ${AUTH_DB_URL} AUTH_DB_USERNAME: ${AUTH_DB_USERNAME} AUTH_DB_PASSWORD: ${AUTH_DB_PASSWORD} - OVERSEAS_JWT_ENABLED: ${OVERSEAS_JWT_ENABLED:-false} - OVERSEAS_JWT_ISSUER: ${OVERSEAS_JWT_ISSUER:-auth-service} - OVERSEAS_JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} - OVERSEAS_JWT_RSA_JWK_SET: ${OVERSEAS_JWT_RSA_JWK_SET:-} - OVERSEAS_JWT_CURRENT_KID: ${OVERSEAS_JWT_CURRENT_KID:-} - OVERSEAS_ACCESS_TOKEN_TTL: ${OVERSEAS_ACCESS_TOKEN_TTL:-15m} iam: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-iam${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PUBLISHED_PORT:-${IAM_PORT:-9201}}:9201"] + container_name: firstleg-test-iam + expose: ["9201"] environment: <<: *common-environment SERVER_PORT: 9201 + NOTIFICATION_SCHEDULER_ENABLED: ${NOTIFICATION_SCHEDULER_ENABLED:-false} + NOTIFICATION_MQ_CONSUMER_ENABLED: ${NOTIFICATION_MQ_CONSUMER_ENABLED:-false} IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} - SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-business${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PUBLISHED_PORT:-${FIRSTLEG_PORT:-9202}}:9202"] + container_name: firstleg-test-business + expose: ["9202"] environment: <<: *common-environment SERVER_PORT: 9202 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} - LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} @@ -121,21 +133,31 @@ services: finance: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-finance${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PUBLISHED_PORT:-${FINANCE_PORT:-9203}}:9203"] + container_name: firstleg-test-finance + expose: ["9203"] environment: <<: *common-environment SERVER_PORT: 9203 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 FINANCE_DB_URL: ${FINANCE_DB_URL} FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} - OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} order: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-order${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PUBLISHED_PORT:-${ORDER_PORT:-9204}}:9204"] + container_name: firstleg-test-order + expose: ["9204"] environment: <<: *common-environment SERVER_PORT: 9204 @@ -146,8 +168,8 @@ services: warehouse: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-warehouse${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PUBLISHED_PORT:-${WAREHOUSE_PORT:-9205}}:9205"] + container_name: firstleg-test-warehouse + expose: ["9205"] environment: <<: *common-environment SERVER_PORT: 9205 @@ -158,8 +180,8 @@ services: file: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-file${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PUBLISHED_PORT:-${FILE_PORT:-9206}}:9206"] + container_name: firstleg-test-file + expose: ["9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" environment: @@ -172,8 +194,6 @@ services: PDA_UPDATE_PUBLIC_BASE_URL: ${PDA_UPDATE_PUBLIC_BASE_URL:-} JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" # Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。 - ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-} - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false} FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4} FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false} @@ -182,8 +202,8 @@ services: print: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-print${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PUBLISHED_PORT:-${PRINT_PORT:-9207}}:9207"] + container_name: firstleg-test-print + expose: ["9207"] environment: <<: *common-environment SERVER_PORT: 9207 @@ -192,8 +212,8 @@ services: integration-service: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-integration${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PUBLISHED_PORT:-${INTEGRATION_PORT:-9210}}:9210"] + container_name: firstleg-test-integration + expose: ["9210"] environment: <<: *common-environment SERVER_PORT: 9210 @@ -238,31 +258,10 @@ services: HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s} HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s} - event-bridge-service: - <<: *common - image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PUBLISHED_PORT:-${EVENT_BRIDGE_PORT:-9312}}:9312"] - environment: - <<: *common-environment - SERVER_PORT: 9312 - EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} - EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} - EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} - EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} - FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} - FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events} - FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events} - SPRING_SQL_INIT_MODE: never - networks: - firstleg-network: - infra-network: - aliases: [event-bridge-cn] - audit-service: <<: *common image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-audit${DEPLOY_SLOT_SUFFIX:-} + container_name: firstleg-test-audit expose: ["9700"] environment: <<: *common-environment @@ -274,9 +273,50 @@ services: AUDIT_DLQ_CONSUMER_ENABLED: ${AUDIT_DLQ_CONSUMER_ENABLED:-false} AUDIT_DLQ_COMPENSATION_ENABLED: ${AUDIT_DLQ_COMPENSATION_ENABLED:-false} + lastleg-service: + <<: *common + image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} + container_name: firstleg-test-lastleg + expose: ["9303"] + environment: + <<: *common-environment + SERVER_PORT: 9303 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 + LASTLEG_DB_URL: ${LASTLEG_DB_URL} + LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} + LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL:-http://warehouse:9205} + FILE_SERVICE_URL: ${FILE_SERVICE_URL:-http://file:9206} + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} + + warehouse-overseas-service: + <<: *common + image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} + container_name: firstleg-test-warehouse-overseas + expose: ["9305"] + environment: + <<: *common-environment + SERVER_PORT: 9305 + WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + networks: firstleg-network: - name: firstleg-${DEPLOY_ENV:-test}-network + name: firstleg-test-network infra-network: external: true name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index ad239b1bf..5826218bd 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine +ARG BASE_IMAGE=eclipse-temurin:17-jre-alpine FROM ${BASE_IMAGE} AS extractor WORKDIR /workspace diff --git a/deploy/env/.env.infra-test.example b/deploy/env/.env.infra-test.example index 583f4d3ec..c80956a07 100644 --- a/deploy/env/.env.infra-test.example +++ b/deploy/env/.env.infra-test.example @@ -7,7 +7,7 @@ MYSQL_PASSWORD=CHANGE_ME_TEST_MYSQL_PASSWORD REDIS_PASSWORD=CHANGE_ME_TEST_REDIS_PASSWORD # RocketMQ 与 File Service 位于同一 Docker 网络,Broker 向客户端公布服务名。 ROCKETMQ_BROKER_IP=rocketmq-broker -# 测试环境必须显式使用 test-;生产环境在对应凭据文件中显式设置为空字符串。 +# 测试业务 MQ topic/group 使用 test- 前缀。 MQ_TOPIC_PREFIX=test- AUDIT_OUTBOX_ENABLED=false AUDIT_OUTBOX_PUBLISHER_ENABLED=false diff --git a/deploy/env/.env.test.example b/deploy/env/.env.test.example new file mode 100644 index 000000000..13dcc01d5 --- /dev/null +++ b/deploy/env/.env.test.example @@ -0,0 +1,54 @@ +# TEST only. Copy outside the repository and supply secrets securely. +NACOS_SERVER_ADDR=host.docker.internal:8848 +NACOS_NAMESPACE= +NACOS_USERNAME= +NACOS_PASSWORD= +REDIS_HOST=host.docker.internal +REDIS_PORT=6379 +REDIS_USERNAME=default +REDIS_DATABASE=0 +REDIS_TOKEN_KEY_PREFIX=baiyue:auth +ROCKETMQ_NAME_SERVER=rocketmq-namesrv:9876 +MQ_TOPIC_PREFIX=test- +JWT_ISSUER=auth-service +JWT_AUDIENCE=lastleg-api +JWT_RSA_JWK_SET= +JWT_CURRENT_KID= +EVENT_OPERATIONS_TOKEN= +INFRA_NETWORK=firstleg-test-infra-network +IMAGE_REPOSITORY=registry.example.com/team/baiyue-firstleg +IMAGE_TAG= +FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads +CORS_ALLOWED_ORIGINS= +INTERNAL_SERVICE_TOKEN= +REDIS_PASSWORD= +AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +AUTH_DB_USERNAME= +AUTH_DB_PASSWORD= +IAM_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +IAM_DB_USERNAME= +IAM_DB_PASSWORD= +FIRSTLEG_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FIRSTLEG_DB_USERNAME= +FIRSTLEG_DB_PASSWORD= +FINANCE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FINANCE_DB_USERNAME= +FINANCE_DB_PASSWORD= +ORDER_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +ORDER_DB_USERNAME= +ORDER_DB_PASSWORD= +WAREHOUSE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +WAREHOUSE_DB_USERNAME= +WAREHOUSE_DB_PASSWORD= +FILE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FILE_DB_USERNAME= +FILE_DB_PASSWORD= +AUDIT_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +AUDIT_DB_USERNAME= +AUDIT_DB_PASSWORD= +LASTLEG_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +LASTLEG_DB_USERNAME= +LASTLEG_DB_PASSWORD= +WAREHOUSE_OVERSEAS_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +WAREHOUSE_OVERSEAS_DB_USERNAME= +WAREHOUSE_OVERSEAS_DB_PASSWORD= diff --git a/deploy/scripts/deploy-blue-green-service.sh b/deploy/scripts/deploy-blue-green-service.sh deleted file mode 100644 index ff2fb20ee..000000000 --- a/deploy/scripts/deploy-blue-green-service.sh +++ /dev/null @@ -1,411 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 7 ]; then - echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件> <项目名>" >&2 - exit 2 -fi - -DEPLOY_ENV="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -SERVICE="$4" -ENV_FILE="$5" -COMPOSE_FILE="$6" -BASE_PROJECT="$7" - -case "$DEPLOY_ENV" in - test|production) ;; - *) echo "蓝绿执行器仅用于 test/production,当前: $DEPLOY_ENV" >&2; exit 2 ;; -esac - -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - [ -f "$file" ] || { echo "必需文件不存在: $file" >&2; exit 2; } -done - -case "$SERVICE" in - *[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2; exit 2 ;; -esac - -env_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -service_contract() { - case "$SERVICE:$COMPOSE_FILE" in - gateway:*) echo "gateway|9000|9000|gateway|gateway-active" ;; - auth:*) echo "auth|9100|9100|auth-service|auth" ;; - iam:*) echo "iam|9201|9201|iam-service|iam" ;; - firstleg:*) echo "business|9202|9202|firstleg-service|firstleg" ;; - finance:*) echo "finance|9203|9203|finance-service|finance" ;; - order:*) echo "order|9204|9204|order-service|order" ;; - warehouse:*) echo "warehouse|9205|9205|warehouse-service|warehouse" ;; - file:*) echo "file|9206|9206|file-service|file" ;; - print:*) echo "print|9207|9207|print-service|print" ;; - integration-service:*) echo "integration|9210|9210|integration-service|integration-service" ;; - audit-service:*) echo "audit|9700|9700|audit-service|audit-service" ;; - gateway-overseas:*) echo "gateway-overseas|9300|9300|gateway-overseas|gateway-overseas-active" ;; - lastleg-service:*) echo "lastleg|9303|9303|lastleg-service|lastleg-service" ;; - warehouse-overseas-service:*) echo "warehouse-overseas|9305|9305|warehouse-overseas-service|warehouse-overseas-service" ;; - event-bridge-service:*overseas*) echo "event-bridge-overseas|9312|9313|event-bridge-overseas|event-bridge-overseas" ;; - event-bridge-service:*) echo "event-bridge|9312|9312|event-bridge-cn|event-bridge-cn" ;; - *) echo "不支持蓝绿部署的服务: $SERVICE" >&2; exit 2 ;; - esac -} - -IFS='|' read -r CONTAINER_STEM INTERNAL_PORT BASE_PORT NACOS_SERVICE ROUTING_ALIAS <&2; exit 2 ;; esac -done - -mkdir -p "$STATE_ROOT" -chmod 750 "$STATE_ROOT" -docker network inspect "$ROUTING_NETWORK" >/dev/null 2>&1 || docker network create "$ROUTING_NETWORK" >/dev/null - -legacy_container="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}" -active_slot="" -active_container="" -active_port="" -active_worker="" -if [ -s "$STATE_FILE" ]; then - IFS='|' read -r active_slot active_container active_port active_image active_worker < "$STATE_FILE" -elif docker inspect "$legacy_container" >/dev/null 2>&1; then - active_slot="legacy" - active_container="$legacy_container" - active_port="$BASE_PORT" -fi - -if [ "$active_slot" = "blue" ]; then - target_slot="green" -else - target_slot="blue" - [ "$active_slot" = "legacy" ] && target_slot="green" -fi - -if [ "$target_slot" = "blue" ]; then - target_port=$((BASE_PORT + 20000)) -else - target_port=$((BASE_PORT + 10000)) -fi -candidate="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}-${target_slot}" -worker_candidate="${candidate}-worker" - -if docker inspect "$candidate" >/dev/null 2>&1; then - if [ "$candidate" = "$active_container" ]; then - echo "活动槽状态异常,候选容器与活动容器相同: $candidate" >&2 - exit 1 - fi - docker rm -f "$candidate" >/dev/null -fi - -compose() { - docker compose --project-name "$BASE_PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -container_health() { - container="$1" - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ - "$container" 2>/dev/null || echo missing -} - -cleanup_candidate() { - docker rm -f "$candidate" >/dev/null 2>&1 || true -} - -wait_for_container() { - container="$1" - label="$2" - elapsed=0 - while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do - state="$(container_health "$container")" - echo "$label 健康状态: $state(已等待 ${elapsed}s)" - case "$state" in - healthy) return 0 ;; - unhealthy|exited|dead|missing) return 1 ;; - esac - sleep "$HEALTH_POLL" - elapsed=$((elapsed + HEALTH_POLL)) - done - return 1 -} - -needs_worker() { - case "$SERVICE" in - iam|firstleg|finance|order|event-bridge-service|lastleg-service) return 0 ;; - file) - [ "$(env_value FILE_EXPORT_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value FILE_EXPORT_PUBLISHER_ENABLED)" = true ] \ - || [ "$(env_value FILE_EXPORT_RECONCILER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ] - ;; - audit-service) - [ "$(env_value AUDIT_MQ_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_DLQ_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_DLQ_COMPENSATION_ENABLED)" = true ] - ;; - auth|warehouse|warehouse-overseas-service) - [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ] - ;; - *) return 1 ;; - esac -} - -start_worker_candidate() { - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - echo "启动单例 Worker: $worker_candidate" - if [ "$SERVICE" = file ]; then - worker_java_options="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" - else - worker_java_options="-Xms256m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" - fi - if ! compose run -d --no-deps --name "$worker_candidate" \ - -e "NACOS_REGISTER_ENABLED=false" \ - -e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=true" \ - -e "BAIYUE_DEPLOY_SLOT=${target_slot}-worker" \ - -e "JAVA_TOOL_OPTIONS=$worker_java_options" \ - -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" "$SERVICE" >/dev/null; then - return 1 - fi - docker network connect "$ROUTING_NETWORK" "$worker_candidate" - if ! wait_for_container "$worker_candidate" "候选 Worker"; then - docker logs --tail 200 "$worker_candidate" 2>&1 || true - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - return 1 - fi - worker_image="$(docker inspect --format '{{.Config.Image}}' "$worker_candidate")" - [ "$worker_image" = "$IMAGE" ] || return 1 -} - -connect_routing_aliases() { - container="$1" - shift - docker network inspect "$ROUTING_NETWORK" --format '{{json .Containers}}' | grep -q "\"$container\"" \ - && return 0 - set -- docker network connect $(for alias_name in "$@"; do printf '%s\n' --alias "$alias_name"; done) - "$@" "$ROUTING_NETWORK" "$container" -} - -bootstrap_legacy_routes() { - for pair in \ - gateway-active,gateway:gateway auth,auth-service:auth iam,iam-service:iam \ - firstleg,firstleg-service:business finance,finance-service:finance order,order-service:order \ - warehouse,warehouse-service:warehouse file,file-service:file print,print-service:print \ - integration-service:integration audit-service:audit \ - gateway-overseas-active,gateway-overseas:gateway-overseas \ - lastleg-service:lastleg warehouse-overseas-service:warehouse-overseas \ - event-bridge-cn:event-bridge event-bridge-overseas:event-bridge-overseas - do - aliases="${pair%%:*}" - stem="${pair#*:}" - container="firstleg-${DEPLOY_ENV}-${stem}" - if docker inspect "$container" >/dev/null 2>&1; then - connect_routing_aliases "$container" $(printf '%s' "$aliases" | tr ',' ' ') || true - fi - done - for container in firstleg-prod-admin-web firstleg-prod-client-web; do - if docker inspect "$container" >/dev/null 2>&1; then - connect_routing_aliases "$container" "${container##*-}" || true - fi - done -} - -switch_gateway_proxy() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - if [ "$DEPLOY_ENV" = test ]; then - echo "测试 Gateway 仍由固定入口承载,不在后端蓝绿执行器中切换" >&2 - return 1 - fi - if [ "$SERVICE" = "gateway" ]; then required_alias=gateway-active; else required_alias=gateway-overseas-active; fi - for frontend_service in admin-web client-web; do - frontend_state="$STATE_ROOT/frontend-$frontend_service.active" - if [ ! -s "$frontend_state" ]; then - echo "$frontend_service 尚未完成首次蓝绿迁移,拒绝切换 Gateway" >&2 - return 1 - fi - IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state" - if ! docker inspect "$frontend" >/dev/null 2>&1 \ - || ! docker exec "$frontend" nginx -T 2>&1 | grep -q "$required_alias"; then - echo "$frontend 尚未使用稳定上游 $required_alias,拒绝切换 Gateway" >&2 - return 1 - fi - done - route_root="${BLUE_GREEN_ROUTE_CONFIG_ROOT:-/www/server/panel/vhost/nginx}" - if [ "$SERVICE" = "gateway" ]; then - config="$route_root/00-blue-green-gateway.conf" - else - config="$route_root/00-blue-green-gateway-overseas.conf" - fi - if [ -z "$config" ] || [ ! -f "$config" ]; then - echo "$SERVICE 蓝绿切换要求环境文件配置有效的 Nginx 配置路径" >&2 - return 1 - fi - backup="$config.blue-green-backup" - cp -f "$config" "$backup" - if ! sed -i "s#127\.0\.0\.1:${active_port:-$BASE_PORT}#127.0.0.1:$target_port#g" "$config" \ - || cmp -s "$config" "$backup"; then - cp -f "$backup" "$config" - echo "未能在 Nginx 配置中切换 $SERVICE 上游端口" >&2 - return 1 - fi - if ! nginx -t || ! nginx -s reload; then - cp -f "$backup" "$config" - nginx -t && nginx -s reload || true - return 1 - fi - rm -f "$backup" -} - -reload_frontend_proxies() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - [ "$DEPLOY_ENV" = production ] || return 0 - for frontend_service in admin-web client-web; do - frontend_state="$STATE_ROOT/frontend-$frontend_service.active" - [ -s "$frontend_state" ] || return 1 - IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state" - docker inspect "$frontend" >/dev/null 2>&1 || return 1 - docker exec "$frontend" nginx -t - docker exec "$frontend" nginx -s reload - done -} - -rollback_gateway_proxy() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - [ "$DEPLOY_ENV" = production ] || return 0 - route_root="${BLUE_GREEN_ROUTE_CONFIG_ROOT:-/www/server/panel/vhost/nginx}" - if [ "$SERVICE" = "gateway" ]; then - config="$route_root/00-blue-green-gateway.conf" - else - config="$route_root/00-blue-green-gateway-overseas.conf" - fi - old_port="${active_port:-$BASE_PORT}" - sed -i "s#127\.0\.0\.1:$target_port#127.0.0.1:$old_port#g" "$config" - nginx -t && nginx -s reload -} - -bootstrap_legacy_routes - -export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG -echo "启动候选槽: service=$SERVICE slot=$target_slot port=$target_port image=$IMAGE" -if ! compose run -d --no-deps --name "$candidate" \ - -e "BAIYUE_DEPLOY_SLOT=$target_slot" -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" \ - -e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=false" \ - -e "AUDIT_OUTBOX_PUBLISHER_ENABLED=false" \ - -e "NOTIFICATION_SCHEDULER_ENABLED=false" \ - -e "NOTIFICATION_MQ_CONSUMER_ENABLED=false" \ - -e "FILE_EXPORT_CONSUMER_ENABLED=false" \ - -e "FILE_EXPORT_PUBLISHER_ENABLED=false" \ - -e "FILE_EXPORT_RECONCILER_ENABLED=false" \ - -e "AUDIT_MQ_CONSUMER_ENABLED=false" \ - -e "AUDIT_DLQ_CONSUMER_ENABLED=false" \ - -e "AUDIT_DLQ_COMPENSATION_ENABLED=false" \ - -p "${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:$target_port:$INTERNAL_PORT" "$SERVICE" >/dev/null; then - cleanup_candidate - exit 1 -fi - -if ! wait_for_container "$candidate" "候选 Web 槽 $target_slot"; then - echo "候选槽未通过健康检查,保留旧实例" >&2 - docker logs --tail 200 "$candidate" 2>&1 || true - cleanup_candidate - exit 1 -fi - -running_image="$(docker inspect --format '{{.Config.Image}}' "$candidate")" -if [ "$running_image" != "$IMAGE" ]; then - echo "候选镜像不一致: expected=$IMAGE actual=$running_image" >&2 - cleanup_candidate - exit 1 -fi - -if [ "$NACOS_SERVICE" = "$ROUTING_ALIAS" ]; then - connect_routing_aliases "$candidate" "$ROUTING_ALIAS" -else - connect_routing_aliases "$candidate" "$ROUTING_ALIAS" "$NACOS_SERVICE" -fi - -if [ "$STABILIZATION_SECONDS" -gt 0 ]; then - echo "候选槽稳定观察 ${STABILIZATION_SECONDS}s" - sleep "$STABILIZATION_SECONDS" - [ "$(container_health "$candidate")" = "healthy" ] || { cleanup_candidate; exit 1; } -fi - -if ! switch_gateway_proxy; then - cleanup_candidate - exit 1 -fi - -worker_required=false -if needs_worker; then worker_required=true; fi - -if [ "$worker_required" = true ] && [ "$active_slot" != legacy ]; then - if [ -n "$active_worker" ] && docker inspect "$active_worker" >/dev/null 2>&1; then - echo "暂停旧 Worker,避免新旧任务并行: $active_worker" - docker stop --time "$STOP_TIMEOUT" "$active_worker" >/dev/null - fi - if ! start_worker_candidate; then - [ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true - rollback_gateway_proxy || true - cleanup_candidate - exit 1 - fi -fi - -old_container_stopped=false -if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then - if [ "$DRAIN_SECONDS" -gt 0 ]; then - echo "蓝绿切换后连接排空 ${DRAIN_SECONDS}s,旧 Web 实例保持运行: $active_container" - sleep "$DRAIN_SECONDS" - fi - echo "优雅停止旧 Web 实例: $active_container" - docker stop --time "$STOP_TIMEOUT" "$active_container" >/dev/null - old_container_stopped=true -fi - -if [ "$worker_required" = true ] && [ "$active_slot" = legacy ]; then - if ! start_worker_candidate; then - [ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true - rollback_gateway_proxy || true - cleanup_candidate - exit 1 - fi -fi - -if ! reload_frontend_proxies; then - [ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true - if [ "$worker_required" = true ]; then - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - [ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true - fi - rollback_gateway_proxy || true - cleanup_candidate - reload_frontend_proxies || true - exit 1 -fi - -[ -z "$active_worker" ] || [ "$active_worker" = "$worker_candidate" ] \ - || docker rm "$active_worker" >/dev/null 2>&1 || true -[ "$old_container_stopped" = false ] || docker rm "$active_container" >/dev/null - -if [ "$worker_required" = true ]; then - next_worker="$worker_candidate" -else - next_worker="" -fi -printf '%s|%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$IMAGE" "$next_worker" > "$STATE_FILE.tmp" -chmod 640 "$STATE_FILE.tmp" -mv -f "$STATE_FILE.tmp" "$STATE_FILE" -echo "蓝绿切换完成: service=$SERVICE active=$target_slot container=$candidate port=$target_port" diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index a658d1e77..3846f5b8d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -28,177 +28,45 @@ for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do esac done -case "$DEPLOY_ENV" in - development|test|production) ;; - *) - echo "仅支持 development/test/production,当前: $DEPLOY_ENV" >&2 - exit 2 - ;; -esac - -if [ "$DEPLOY_ENV" = "development" ]; then - PUBLISHED_BIND_ADDRESS=127.0.0.1 -else - PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" -fi -export PUBLISHED_BIND_ADDRESS +[ "$DEPLOY_ENV" = "test" ] || { echo "Only test is supported" >&2; exit 2; } case "$SERVICE" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $SERVICE" >&2 - exit 2 - ;; + gateway|auth|iam|firstleg|finance|order|warehouse|file|print|integration-service|lastleg-service|warehouse-overseas-service|audit-service) ;; + *) echo "Unknown or retired service: $SERVICE" >&2; exit 2 ;; esac - -if [ ! -f "$ENV_FILE" ]; then - echo "必需文件不存在: $ENV_FILE" >&2 - exit 2 -fi - -env_file_has_key() { - grep -q "^$1=" "$ENV_FILE" -} - -env_file_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" - ;; - event-bridge-service) - BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" - if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then - BRIDGE_REGION="$(env_file_value REGION_CODE)" - fi - if [ -z "$BRIDGE_REGION" ]; then - echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 - exit 2 - fi - case "$BRIDGE_REGION" in - CN) ;; - OVERSEAS) - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" - ;; - *) - echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2 - exit 2 - ;; - esac - ;; -esac - -if [ "$SERVICE" = "gateway-overseas" ]; then - if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then - echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 - exit 2 - fi -fi - -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - if [ ! -f "$file" ]; then - echo "必需文件不存在: $file" >&2 - exit 2 - fi +[ "${TARGET_IS_LOCAL:-false}" = "true" ] || { echo "TEST deploy must run on the target host" >&2; exit 2; } +[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } +env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } +for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done - -if [ "$SERVICE" = "finance" ]; then - for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi +[ "$(env_file_value MQ_TOPIC_PREFIX)" = "test-" ] || { echo "MQ_TOPIC_PREFIX must be test-" >&2; exit 2; } +case "$SERVICE" in + gateway|print|integration-service) db_prefix='' ;; + lastleg-service) db_prefix=LASTLEG ;; + warehouse-overseas-service) db_prefix=WAREHOUSE_OVERSEAS ;; + audit-service) db_prefix=AUDIT ;; + *) db_prefix=$(printf '%s' "$SERVICE" | tr '[:lower:]' '[:upper:]') ;; +esac +if [ -n "$db_prefix" ]; then + for suffix in URL USERNAME PASSWORD; do + key="${db_prefix}_DB_${suffix}" + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done fi - -if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then - for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署海外服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "firstleg" ]; then - if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then - echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 - exit 2 - fi - if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then - for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - fi -fi - -if [ "$SERVICE" = "lastleg-service" ]; then - for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi +if [ "$SERVICE" = "firstleg" ] && [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then + for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done fi - -if [ "$SERVICE" = "warehouse-overseas-service" ]; then - for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "event-bridge-service" ]; then - if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" - else - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" - fi - for key in $EVENT_BRIDGE_KEYS; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "file" ]; then - if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then - echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 - exit 2 - fi - if ! env_file_has_key MQ_TOPIC_PREFIX; then - echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX" >&2 - exit 2 - fi - MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) - if [ "$DEPLOY_ENV" = "test" ] && [ "$MQ_PREFIX" != "test-" ]; then - echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 - exit 2 - fi - if [ "$DEPLOY_ENV" = "development" ] && { [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; }; then - echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2 - exit 2 - fi - if [ "$DEPLOY_ENV" = "production" ] && [ -n "$MQ_PREFIX" ]; then - echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为空,使用正式 Topic" >&2 - exit 2 - fi -fi - +# Architecture/schema changes must use whole-release recovery, never an implicit old image. +export PUBLISHED_BIND_ADDRESS=127.0.0.1 : "${REGISTRY_USER:?REGISTRY_USER 未设置}" : "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" local_registry_logout() { docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true + if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi } local_compose() { @@ -255,8 +123,8 @@ wait_for_local_health() { return 0 ;; no-health:running) - echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2 - return 0 + echo "Missing HEALTHCHECK; deployment failed" >&2 + return 1 ;; unhealthy|no-health:exited|no-health:dead) return 1 @@ -270,6 +138,10 @@ wait_for_local_health() { } deploy_local() { + deploy_docker_config=$(mktemp -d) + DOCKER_CONFIG="$deploy_docker_config" + export DOCKER_CONFIG + trap local_registry_logout EXIT HUP INT TERM printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" trap local_registry_logout EXIT HUP INT TERM previous_image="$(local_container_image || true)" @@ -306,6 +178,11 @@ deploy_local() { else echo "新镜像容器不存在,无法读取启动日志" >&2 fi + if [ "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" != "true" ]; then + echo "Automatic rollback disabled: keep writes closed and recover the matched release" >&2 + local_compose stop "$SERVICE" >/dev/null 2>&1 || true + return 1 + fi expected_prefix="$IMAGE_REPOSITORY/$SERVICE:" case "$previous_image" in "$expected_prefix"*) @@ -346,241 +223,4 @@ deploy_local() { return 1 } -if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then - echo "镜像已在目标服务器本机,直接部署: $IMAGE" - if [ "$DEPLOY_ENV" = "production" ]; then - printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" - trap local_registry_logout EXIT HUP INT TERM - docker pull "$IMAGE" - sh deploy/scripts/deploy-blue-green-service.sh \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME" - local_registry_logout - trap - EXIT HUP INT TERM - exit 0 - fi - deploy_local - exit 0 -fi - -SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" -SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" -SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" -REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" - -for file in "$SSH_KEY" "$SSH_KNOWN_HOSTS"; do - if [ ! -f "$file" ]; then - echo "SSH 文件不存在: $file" >&2 - exit 2 - fi -done - -run_ssh() { - ssh -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o Compression=no \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o Compression=no \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" - -SUFFIX="${IMAGE_TAG}.$$.${SERVICE}" -REMOTE_ENV_NAME=".env.$PROJECT_NAME" -REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml" -REMOTE_BLUE_GREEN_NAME="deploy-blue-green-service.sh" -REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp" -REMOTE_BLUE_GREEN_TMP="$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME.$SUFFIX.tmp" -run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" -run_scp "deploy/scripts/deploy-blue-green-service.sh" "$SSH_TARGET:$REMOTE_BLUE_GREEN_TMP" -run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_BLUE_GREEN_TMP" \ - "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" <<'REMOTE_PREPARE' -set -eu -env_tmp="$1" -compose_tmp="$2" -blue_green_tmp="$3" -env_target="$4" -compose_target="$5" -blue_green_target="$6" -chmod 600 "$env_tmp" -chmod 644 "$compose_tmp" -chmod 750 "$blue_green_tmp" -mv -f "$env_tmp" "$env_target" -mv -f "$compose_tmp" "$compose_target" -mv -f "$blue_green_tmp" "$blue_green_target" -REMOTE_PREPARE - -remote_registry_logout() { - run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true -} - -echo "目标服务器从共享仓库拉取增量镜像: $IMAGE" -printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" -trap remote_registry_logout EXIT HUP INT TERM -run_ssh docker pull "$IMAGE" - -if [ "$DEPLOY_ENV" = "production" ]; then - echo "$DEPLOY_ENV 服务使用蓝绿执行器,旧实例在候选槽健康前保持运行" - run_ssh sh "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" \ - "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$PROJECT_NAME" - remote_registry_logout - trap - EXIT HUP INT TERM - exit 0 -fi - -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -deploy_env="$2" -image_repository="$3" -image_tag="$4" -service="$5" -project_name="$6" -env_file="$7" -compose_file="$8" -health_timeout_seconds="$9" -health_poll_seconds="${10}" -cd "$remote_dir" -export DEPLOY_ENV="$deploy_env" -export IMAGE_REPOSITORY="$image_repository" -export IMAGE_TAG="$image_tag" -if [ "$deploy_env" = "development" ]; then - export PUBLISHED_BIND_ADDRESS=127.0.0.1 -else - export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" -fi - -compose() { - docker compose \ - --project-name "$project_name" \ - --env-file "$env_file" \ - -f "$compose_file" \ - "$@" -} - -container_id() { - compose ps --all --quiet "$service" | head -n 1 -} - -container_image() { - id="$(container_id)" - if [ -n "$id" ]; then - docker inspect --format '{{.Config.Image}}' "$id" - fi -} - -health_state() { - id="$(container_id)" - if [ -z "$id" ]; then - echo missing - return - fi - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$id" 2>/dev/null || echo missing -} - -wait_for_health() { - elapsed=0 - while [ "$elapsed" -lt "$health_timeout_seconds" ]; do - state="$(health_state)" - echo "容器健康状态: $state(已等待 ${elapsed}s)" - case "$state" in - healthy) return 0 ;; - no-health:running) - echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2 - return 0 - ;; - unhealthy|no-health:exited|no-health:dead) return 1 ;; - esac - sleep "$health_poll_seconds" - elapsed=$((elapsed + health_poll_seconds)) - done - echo "等待容器健康超时: ${health_timeout_seconds}s" >&2 - return 1 -} - -new_image="$image_repository/$service:$image_tag" -previous_image="$(container_image || true)" -if [ -n "$previous_image" ]; then - echo "部署前运行镜像: $previous_image" -else - echo "首次部署,未发现可回滚的旧镜像" -fi - -new_started=true -if ! compose up -d --no-deps --pull never "$service"; then - echo "新镜像启动命令失败: $new_image" >&2 - new_started=false -fi -if [ "$new_started" = "true" ] && wait_for_health; then - running_image="$(container_image || true)" - if [ "$running_image" != "$new_image" ]; then - echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2 - exit 1 - fi - echo "新镜像健康检查通过: $new_image" - compose ps "$service" - exit 0 -fi - -echo "新镜像健康检查失败: $new_image" >&2 -failed_container_id="$(container_id || true)" -if [ -n "$failed_container_id" ]; then - echo "新镜像容器最近日志(最多 200 行):" >&2 - docker logs --tail 200 "$failed_container_id" 2>&1 || true -else - echo "新镜像容器不存在,无法读取启动日志" >&2 -fi -expected_prefix="$image_repository/$service:" -case "$previous_image" in - "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; - '') - echo "首次部署没有可回滚镜像,部署失败" >&2 - compose stop "$service" >/dev/null 2>&1 || true - exit 1 - ;; - *) - echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2 - exit 1 - ;; -esac -if [ "$previous_image" = "$new_image" ]; then - echo "旧镜像与新镜像相同,无法自动回滚: $new_image" >&2 - exit 1 -fi - -echo "开始自动回滚到: $previous_image" >&2 -if ! docker pull "$previous_image"; then - echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2 - exit 1 -fi -IMAGE_TAG="$previous_tag" -export IMAGE_TAG -if ! compose up -d --no-deps --pull never "$service"; then - echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2 - exit 1 -fi -if wait_for_health; then - echo "自动回滚成功: $previous_image" >&2 - compose ps "$service" || true -else - echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2 -fi -exit 1 -REMOTE_DEPLOY - -remote_registry_logout -trap - EXIT HUP INT TERM +deploy_local diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh deleted file mode 100644 index 23343f7a4..000000000 --- a/deploy/scripts/deploy-development-service.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 4 ]; then - echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 - exit 2 -fi - -IMAGE_REPOSITORY="$1" -IMAGE_TAG="$2" -SERVICE_CSV="$3" -ENV_FILE="$4" - -SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" -SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" -SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" -REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" -COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}" -PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}" - -if [ ! -f "$SSH_KEY" ]; then - echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 - exit 2 -fi -if [ ! -f "$SSH_KNOWN_HOSTS" ]; then - echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2 - exit 2 -fi -if [ ! -f "$ENV_FILE" ]; then - echo "开发环境变量文件不存在: $ENV_FILE" >&2 - exit 2 -fi -if [ ! -f "$COMPOSE_FILE" ]; then - echo "Compose 文件不存在: $COMPOSE_FILE" >&2 - exit 2 -fi - -SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') -for service in $SERVICES; do - case "$service" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $service" >&2 - exit 2 - ;; - esac -done - -if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then - echo "开发镜像已在开发节点本机构建,直接部署: $SERVICE_CSV" - install -d -m 750 "$REMOTE_DIR" - - SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" - LOCAL_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" - LOCAL_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" - install -m 600 "$ENV_FILE" "$LOCAL_ENV_TMP" - install -m 644 "$COMPOSE_FILE" "$LOCAL_COMPOSE_TMP" - mv -f "$LOCAL_ENV_TMP" "$REMOTE_DIR/.env" - mv -f "$LOCAL_COMPOSE_TMP" "$REMOTE_DIR/docker-compose.yml" - - for service in $SERVICES; do - docker image inspect "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" >/dev/null - done - - cd "$REMOTE_DIR" - export DEPLOY_ENV=development - export IMAGE_REPOSITORY IMAGE_TAG - export PUBLISHED_BIND_ADDRESS=127.0.0.1 - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - pull $SERVICES - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never $SERVICES - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - ps $SERVICES - exit 0 -fi - -run_ssh() { - ssh -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" - -SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" -REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" - -run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" - -run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE' -set -eu -remote_dir="$1" -env_tmp="$2" -compose_tmp="$3" -chmod 600 "$env_tmp" -chmod 644 "$compose_tmp" -mv -f "$env_tmp" "$remote_dir/.env" -mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" -REMOTE_PREPARE - -for service in $SERVICES; do - image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG" - docker image inspect "$image" >/dev/null - echo "传输开发镜像: $image" - docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' -done - -run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -image_repository="$2" -image_tag="$3" -service_csv="$4" -project_name="$5" -services=$(printf '%s' "$service_csv" | tr ',' ' ') - -cd "$remote_dir" -export DEPLOY_ENV=development -export IMAGE_REPOSITORY="$image_repository" -export IMAGE_TAG="$image_tag" -export PUBLISHED_BIND_ADDRESS=127.0.0.1 - -docker compose \ - --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never $services - -docker compose \ - --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ - ps $services -REMOTE_DEPLOY diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 8c720a1fe..cbff74731 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -1,225 +1,7 @@ #!/usr/bin/env sh set -eu - -if [ "$#" -ne 5 ]; then - echo "用法: $0 <环境> <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 - exit 2 -fi - -DEPLOY_ENV="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -SERVICE_CSV="$4" -ENV_FILE="$5" - -if [ ! -f "$ENV_FILE" ]; then - echo "环境文件不存在: $ENV_FILE" >&2 - exit 2 -fi - -SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') - -for service in $SERVICES; do - case "$service" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $service" >&2 - exit 2 - ;; - esac +[ "$#" -eq 5 ] || { echo "Usage: $0 test repository tag service-csv env-file" >&2; exit 2; } +[ "$1" = "test" ] || exit 2 +for service in $(printf '%s' "$4" | tr ',' ' '); do + sh deploy/scripts/deploy-built-service.sh "$1" "$2" "$3" "$service" "$5" done - -env_file_has_key() { - grep -q "^$1=" "$ENV_FILE" -} - -env_file_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -DEPLOY_REGION=CN -for service in $SERVICES; do - case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; - event-bridge-service) - BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" - if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then - BRIDGE_REGION="$(env_file_value REGION_CODE)" - fi - if [ -z "$BRIDGE_REGION" ]; then - echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 - exit 2 - fi - case "$BRIDGE_REGION" in - CN) ;; - OVERSEAS) DEPLOY_REGION=OVERSEAS ;; - *) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;; - esac - ;; - esac -done -if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - for service in $SERVICES; do - case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; - *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; - esac - done - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas" -else - COMPOSE_FILE="deploy/docker-compose.yml" - PROJECT_NAME="firstleg-${DEPLOY_ENV}" -fi - -if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署海外服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -case " $SERVICES " in - *" gateway-overseas "*) - if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then - echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 - exit 2 - fi - ;; -esac - -case " $SERVICES " in - *" finance "*) - for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" firstleg "*) - if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then - echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 - exit 2 - fi - ;; -esac - -case " $SERVICES " in - *" lastleg-service "*) - for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" warehouse-overseas-service "*) - for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" event-bridge-service "*) - if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" - else - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" - fi - for key in $EVENT_BRIDGE_KEYS; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" file "*) - if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then - echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 - exit 2 - fi - if ! env_file_has_key MQ_TOPIC_PREFIX; then - echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX:测试填 test-,生产填空字符串" >&2 - exit 2 - fi - MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) - case "$DEPLOY_ENV" in - test) - if [ "$MQ_PREFIX" != "test-" ]; then - echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 - exit 2 - fi - ;; - production) - if [ -n "$MQ_PREFIX" ]; then - echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须显式为空字符串" >&2 - exit 2 - fi - ;; - *) - if [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; then - echo "预发布环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为非空且不能使用 test-" >&2 - exit 2 - fi - ;; - esac - ;; -esac - -export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG - -if [ "$DEPLOY_ENV" = "development" ]; then - DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \ - "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" -fi - -if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - pull $SERVICES - for service in $SERVICES; do - if [ "$DEPLOY_ENV" = test ]; then - docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \ - up -d --no-deps --pull never "$service" - continue - fi - sh deploy/scripts/deploy-blue-green-service.sh \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$service" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME" - done - exit 0 -fi - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - pull $SERVICES - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - up -d --remove-orphans --pull never $SERVICES - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - ps $SERVICES diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh index 9e97a52b6..f4960bd38 100644 --- a/deploy/scripts/initialize-test-databases.sh +++ b/deploy/scripts/initialize-test-databases.sh @@ -1,47 +1,15 @@ #!/usr/bin/env sh set -eu - -# 仅用于测试基础设施容器。所有 SQL 均使用 CREATE ... IF NOT EXISTS 或幂等扩展语句, -# 不删除已有业务数据;仓库样例数据使用固定 ID 重复写入。 -MYSQL_CONTAINER="${TEST_MYSQL_CONTAINER:-firstleg-test-mysql}" - -if ! docker inspect --type container "$MYSQL_CONTAINER" >/dev/null 2>&1; then - echo "测试 MySQL 容器不存在:$MYSQL_CONTAINER" >&2 - echo "请先执行 docker compose -f docker-compose-infra.yml up -d" >&2 - exit 1 -fi - -if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; then - echo "测试 MySQL 容器未运行:$MYSQL_CONTAINER" >&2 - exit 1 -fi - -for sql_file in \ - auth/src/main/resources/db/auth.sql \ - iam/src/main/resources/db/iam.sql \ - auth/src/main/resources/db/phase1-admin-accounts.sql \ - iam/src/main/resources/db/phase1-admin-rbac.sql \ - iam/src/main/resources/db/phase1-onboarding.sql \ - iam/src/main/resources/db/phase1-customer-profile.sql \ - iam/src/main/resources/db/phase1-member-permissions.sql \ - iam/src/main/resources/db/rbac-role-management-v2.sql \ - iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \ - order/src/main/resources/db/order.sql \ - order/src/main/resources/db/phase1-order-extension.sql \ - order/src/main/resources/db/phase1-masterdata-extension.sql \ - warehouse/src/main/resources/db/warehouse.sql \ - warehouse/src/main/resources/db/warehouse-partner.sql \ - warehouse/src/main/resources/db/warehouse-express-company.sql \ - warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \ - warehouse/src/main/resources/db/warehouse-seed.sql \ - file/src/main/resources/db/file.sql -do - if [ ! -f "$sql_file" ]; then - echo "找不到初始化 SQL:$sql_file" >&2 - exit 1 - fi - echo "初始化测试数据库:$sql_file" - docker exec -i "$MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" +# Empty-schema baseline only. Never invoked by Compose/Jenkins automatically. +[ "${ALLOW_TEST_SCHEMA_INITIALIZATION:-false}" = "true" ] || { echo 'Explicit TEST SQL authorization required' >&2; exit 2; } +: "${TEST_MYSQL_CONTAINER:?Specify the authorized TEST MySQL container}" +[ "$#" -eq 2 ] || { echo 'Usage: initialize-test-databases.sh ' >&2; exit 2; } +for sql_file in "$1" "$2"; do + [ -f "$sql_file" ] || { echo "Missing reviewed baseline: $sql_file" >&2; exit 2; } +done +[ "$1" != "$2" ] || { echo 'Two distinct reviewed baselines required' >&2; exit 2; } +[ "$(docker inspect -f '{{.State.Running}}' "$TEST_MYSQL_CONTAINER")" = 'true' ] || { echo 'TEST MySQL is not running' >&2; exit 1; } +for sql_file in "$1" "$2"; do + echo "Applying reviewed TEST baseline: $sql_file" + docker exec -i "$TEST_MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" done - -echo "测试数据库初始化完成。" diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..7fefc9ca5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -3,11 +3,6 @@ services: artifactId: gateway jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar port: 9000 - gateway-overseas: - artifactId: gateway-overseas - jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar - port: 9300 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar @@ -44,17 +39,10 @@ services: artifactId: lastleg-service jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar port: 9303 - regions: [OVERSEAS] warehouse-overseas-service: artifactId: warehouse-overseas-service jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar port: 9305 - regions: [OVERSEAS] - event-bridge-service: - artifactId: event-bridge-service - jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar - port: 9312 - regions: [CN, OVERSEAS] audit-service: artifactId: audit-service jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1045b098b..0729767aa 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,4 +1,4 @@ -# 区域数据库空库初始化 +# 单测试环境数据库空库初始化 ## 唯一入口与区域边界 @@ -6,11 +6,11 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 135 | -- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 -- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 +- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 +- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/135`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -44,9 +44,9 @@ | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | -| Lastleg | 国内固定管理员对应的 USER/AUTHORIZATION version=0 安全投影,当前角色及完整 106 项海外权限 | 客户、订单、库存、发运门禁事实或新账号 | +| Lastleg | 无身份或授权种子;复用统一Auth/IAM会话 | 客户、订单、库存、发运门禁事实或独立账号 | -海外管理员投影只是国内已定义身份的初始化快照,不是独立账号或权限中心。后续变更仍由 IAM 事件同步,不提供历史投影回填。 +IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授权投影表已退出新空库基线。旧库不在本次脚本中执行删除或历史回填。 扣货写权限只默认授予平台管理员,与 IAM 扣货权限合同一致;旧脚本重放时通过范围授权额外授予订单运营的那一条关联不进入新基线。 diff --git a/deploy/sql/migration-scopes.json b/deploy/sql/migration-scopes.json index ebe28e67e..aecc3c5a6 100644 --- a/deploy/sql/migration-scopes.json +++ b/deploy/sql/migration-scopes.json @@ -2,7 +2,6 @@ "schemaVersion": 1, "services": [ "gateway", - "gateway-overseas", "auth", "iam", "order", @@ -10,7 +9,6 @@ "firstleg", "file", "audit-service", - "event-bridge-service", "finance", "integration-service", "print", @@ -19,7 +17,7 @@ ], "regions": { "CN": { - "baseline": "baseline/cn/cn-baseline.sql", + "baseline": "头程数据库统一增量-MySQL8.sql", "schemas": [ { "name": "baiyue_auth", "services": ["auth"], "order": 10 }, { "name": "baiyue_iam", "services": ["iam"], "order": 20 }, @@ -28,16 +26,14 @@ { "name": "baiyue_firstleg", "services": ["firstleg"], "order": 50 }, { "name": "baiyue_file", "services": ["file"], "order": 60 }, { "name": "baiyue_audit", "services": ["audit-service"], "order": 70 }, - { "name": "baiyue_event_bridge", "services": ["event-bridge-service"], "order": 80 }, { "name": "baiyue_finance", "services": ["finance"], "order": 90 } ] }, "OVERSEAS": { - "baseline": "baseline/overseas/overseas-baseline.sql", + "baseline": "海外区域统一增量-MySQL8.sql", "schemas": [ { "name": "baiyue_lastleg", "services": ["lastleg-service"], "order": 10 }, - { "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 }, - { "name": "baiyue_event_bridge_overseas", "services": ["event-bridge-service"], "order": 30 } + { "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 } ] } } diff --git a/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql b/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql new file mode 100644 index 000000000..25c2b32ae --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: CN +-- Schema: baiyue_finance +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql b/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql new file mode 100644 index 000000000..37bcf1e53 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: CN +-- Schema: baiyue_finance +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql new file mode 100644 index 000000000..65a2dc56d --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql new file mode 100644 index 000000000..c0d3cd9c8 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql new file mode 100644 index 000000000..dba0a5a22 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql new file mode 100644 index 000000000..deb635d30 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c7ffeb09f..5a75105ef 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1,5 +1,5 @@ -- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 @@ -319,25 +319,6 @@ CREATE TABLE iam_role_permission ( KEY idx_rp_tenant (tenant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源'; -CREATE TABLE iam_security_event_outbox ( - event_id varchar(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', - event_type varchar(64) NOT NULL COMMENT '安全事件类型', - aggregate_type varchar(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', - aggregate_id varchar(64) NOT NULL COMMENT '聚合标识', - payload_json json NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', - attempt_count int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', - next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', - lease_owner varchar(128) DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约到期时间', - last_error varchar(1000) DEFAULT NULL COMMENT '最近一次错误', - sent_at datetime(3) DEFAULT NULL COMMENT '发送成功时间', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (event_id), - KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), - KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM可靠安全事件本地发送箱'; CREATE TABLE iam_security_subject_version ( auth_user_id bigint NOT NULL COMMENT '认证账号ID', @@ -4661,88 +4642,6 @@ CREATE TABLE sys_audit_log ( KEY idx_audit_request (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; --- 服务 schema:baiyue_event_bridge -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge; - -CREATE TABLE bridge_delivery_attempt ( - id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - attempt_no int NOT NULL COMMENT '投递序号', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', - response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', - error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', - started_at datetime(3) NOT NULL COMMENT '开始时间', - finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件投递尝试记录'; - -CREATE TABLE bridge_inbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', - retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件已接收箱'; - -CREATE TABLE bridge_outbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件待投递箱'; - -- 服务 schema:baiyue_finance CREATE DATABASE IF NOT EXISTS baiyue_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_finance; @@ -8142,4 +8041,78 @@ CREATE TABLE fl_sailing_booking_link ( CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') ) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; +USE baiyue_firstleg; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_finance; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_firstleg; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +USE baiyue_finance; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 176fe0178..8f54c1129 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,93 +1,11 @@ -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci; SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; --- 服务 schema:baiyue_event_bridge_overseas -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge_overseas; - -CREATE TABLE bridge_delivery_attempt ( - id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - attempt_no int NOT NULL COMMENT '投递序号', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', - response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', - error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', - started_at datetime(3) NOT NULL COMMENT '开始时间', - finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件投递尝试记录'; - -CREATE TABLE bridge_inbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', - retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件已接收箱'; - -CREATE TABLE bridge_outbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件待投递箱'; - -- 服务 schema:baiyue_lastleg CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastleg; @@ -2523,36 +2441,7 @@ CREATE TABLE ll_return_receipt_item ( CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; -CREATE TABLE ll_security_event_inbox ( - event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨区安全事件ID', - event_type varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'IAM安全事件类型', - payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256哈希', - payload_json json NOT NULL COMMENT '冻结事件载荷JSON', - status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', - result varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', - received_at datetime(3) NOT NULL COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全事件消费者收件箱'; -CREATE TABLE ll_security_projection ( - aggregate_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', - aggregate_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户或客户聚合ID', - tenant_id bigint NOT NULL COMMENT '平台0或客户ID', - security_status varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前安全状态:ACTIVE或DISABLED', - permission_version bigint DEFAULT NULL COMMENT '当前单调权限版本', - roles_json json NOT NULL COMMENT 'IAM投影的当前角色快照', - authorities_json json NOT NULL COMMENT 'IAM投影的当前海外权限快照', - aggregate_version bigint NOT NULL COMMENT '事件聚合单调版本', - payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '当前版本载荷哈希', - last_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后应用的事件ID', - changed_at datetime(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at datetime(3) NOT NULL COMMENT '投影更新时间', - PRIMARY KEY (aggregate_type,aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id,permission_version) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全状态与权限版本投影'; CREATE TABLE ll_us_truck_order ( id bigint NOT NULL COMMENT '主键ID', @@ -3064,9 +2953,43 @@ CREATE TABLE wh_resource_lock ( USE baiyue_lastleg; --- 系统基线 ll_security_projection:2 行。 -INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); -INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -- assert_relabel_transfer_latest_schema_ready: 贴标申请与仓储转运最新表结构已完整部署。 -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 138 AS initialized_tables; +USE baiyue_lastleg; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_lastleg; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 135 AS initialized_tables; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 522ab5e45..88c858837 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 20 个 Maven 模块,其中 7 个是公共/依赖模块,13 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 +当前源码与发布清单统一为 TEST 单机、一个 Gateway/Auth 会话和 Nacos 命名空间;业务服务保留独立 Schema,可靠事件使用 Outbox 直接投递及消费幂等。真实环境切换仍待验收,不能把本地配置视为已部署。 ## 2. 根目录结构 @@ -39,7 +39,7 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 -├─ gateway-overseas/ # 海外区域 API 网关 +├─ gateway-overseas/ # 退役待清理,不参与构建/发布 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 @@ -52,7 +52,7 @@ baiyue-firstleg/ ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 ├─ integration-service/ # 第三方平台集成适配 -├─ event-bridge-service/ # 国内外跨区域事件接收桥 +├─ event-bridge-service/ # 退役待清理,不参与构建/发布 │ ├─ config/ # Nacos 配置模板 ├─ deploy/ # Jenkins、Docker 使用的部署文件 @@ -90,7 +90,6 @@ baiyue-firstleg/ | 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 | | --- | --- | ---: | --- | --- | | `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 | -| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 | | `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 | | `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 | | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | @@ -99,9 +98,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、出库、派送和本土业务;复用统一会话鉴权 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。 @@ -158,9 +156,9 @@ Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权 Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。入库测量与计费重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。 -### 5.11 `event-bridge-service/`:跨区域事件桥 +### 5.11 业务事件直接投递 -Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 +Firstleg、Lastleg 与 Finance 通过内部服务身份直接投递业务事件,保留事务 Outbox、消费幂等、结构化 ACK 与失败重试;精确事件表只维护在外层 `docs/统一认证与服务通信契约.md`。旧 Event Bridge 和海外 Gateway 源码退役待清理,不作为当前实现入口。 头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 @@ -170,11 +168,10 @@ Event Bridge 负责接收国内外服务器之间的必要业务事件,并通 ```text config/nacos/ -├─ local/ # 本地联调配置 -└─ cn/ # 国内服务器配置模板 +└─ test/ # 公共配置 + 13 服务,统一实际 Namespace ID ``` -`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`test/` 保存公共配置和全部保留服务的 14 个 Data ID;旧区域目录已退役。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -183,17 +180,16 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 国内区域服务编排 -├─ docker-compose.overseas.yml # 海外区域服务编排 +├─ docker-compose.yml # 13 服务统一 TEST 编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 -├─ sql/ # 测试/存量环境统一迁移入口与说明 +├─ sql/ # TEST 空库基线入口与执行说明 └─ service-map.yml # 模块、JAR、端口映射 ``` ### `scripts/` - `local-env.ps1`:本地服务环境变量 -- `publish-local-nacos-config.ps1`:发布本地 Nacos 配置 +- `publish-nacos-config.ps1`:发布统一 TEST Nacos 配置;DryRun 不联网,正式发布须授权 - `phase1-acceptance.ps1`:订单与客户验收入口 - `phase1-business-maturity-regression.ps1`:业务成熟度回归 - `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证 @@ -281,6 +277,8 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的13 个保留服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 +- 国内头程与海外仓共用 TEST 单机基础设施与 Nacos 命名空间,保留各服务数据归属和 Schema。 + +前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。 diff --git a/file/src/main/resources/application-dev.yml b/file/src/main/resources/application-dev.yml deleted file mode 100644 index 334396130..000000000 --- a/file/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} diff --git a/file/src/main/resources/application-test.yml b/file/src/main/resources/application-test.yml deleted file mode 100644 index fec0b48d0..000000000 --- a/file/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${FILE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${FILE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index be91f53d6..4c5595c8c 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -2,24 +2,24 @@ server: port: ${FILE_PORT:9206} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: file-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} openfeign: client: config: @@ -49,8 +49,7 @@ rocketmq: send-message-timeout: 3000 baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} clients: iam: url: ${IAM_SERVICE_URL:} diff --git a/finance/AGENTS.md b/finance/AGENTS.md index b4b5d848d..cffd9fd56 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -9,6 +9,8 @@ ## 快速查询 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/finance/event-operations/{entryId}` 查询及 `/retry` 写入,只允许资金结果事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + | 目标 | 首选入口 | |---|---| | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | @@ -32,7 +34,7 @@ | 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` | | 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` | | 数据库 | `src/main/resources/db/finance.sql`、`src/main/resources/db/fee-source-event-migration.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | -| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` | +| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/test/finance-service.yml`、`../gateway/src/main/resources/application.yml` | | 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` | | 测试环境全链路验收模块 | `../scripts/admin-test-env-modules/finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs` 及同名 `*-contract.test.mjs`;仅表示验收入口,不代表环境已部署或验收通过 | | 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` | diff --git a/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java b/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java new file mode 100644 index 000000000..d24be19e6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/finance/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("fin_wallet_event_outbox", "event_id", "status", Set.of(EventTypes.WALLET_COMMAND_COMPLETED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index dd3f867d6..9049e00da 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -6,7 +6,6 @@ import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -36,9 +35,8 @@ public class BusinessFeeSourceEventService { throw error("FIN-EVT-001", "跨区费用事件信封无效"); } if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType()) - || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS - || envelope.targetRegion() != RegionCode.CN) { - throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效"); + || envelope.eventVersion() != 1) { + throw error("FIN-EVT-001", "跨区费用事件类型或版本无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index 5677355b4..e42137930 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.common.event.WalletCommandRequestedV1; import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; @@ -108,9 +107,8 @@ public class WalletCommandEventService { try { EventEnvelopeValidator.requireValid(envelope); } catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); } if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType()) - || envelope.eventVersion() != WalletCommandRequestedV1.VERSION - || envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) { - throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效"); + || envelope.eventVersion() != WalletCommandRequestedV1.VERSION) { + throw new FinanceEventException("FIN-EVT-001", "资金指令类型或版本无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); @@ -146,7 +144,7 @@ public class WalletCommandEventService { JsonNode payload = objectMapper.valueToTree(result); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED, - WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, + WalletCommandCompletedV1.VERSION, Instant.now(), String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java index ba02103bc..d338d3de2 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -1,6 +1,12 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; import com.baiyue.common.core.retry.BoundedRetryPolicy; @@ -21,8 +27,10 @@ import java.util.UUID; @Component @RequiredArgsConstructor @Slf4j -public class WalletResultOutboxRelay { +public class WalletResultOutboxRelay implements EventDeliveryProducer { private final JdbcTemplate jdbc; + private final EventDeliveryJournal journal; + private final EventDeliveryMetrics metrics; private final ObjectMapper objectMapper; private final WalletResultLastlegClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -42,16 +50,21 @@ public class WalletResultOutboxRelay { var policy = retryPolicy(); String worker = UUID.randomUUID().toString(); LocalDateTime now = LocalDateTime.now(); - int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", - worker, now, now, eventId, now); - if (claimed != 1) return; - Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + if (!journal.begin(worker, Stream.FINANCE.service, "WalletCommandCompleted", eventId, eventId, + Stream.FINANCE.target("WalletCommandCompleted"), now, + () -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + worker, now, now, eventId, now) == 1)) return; + Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker); int failures = ((Number) row.get("retry_count")).intValue(); if (policy.exhausted(failures)) { - jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - "自动投递次数已耗尽(含租约超时)", eventId, worker); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + "自动投递次数已耗尽(含租约超时)", eventId, worker)); return; } + Outcome outcome; + String diagnostic = null; + String auditError = null; boolean requestStarted = false; try { EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); @@ -59,21 +72,31 @@ public class WalletResultOutboxRelay { var response = client.receive(internalToken, envelope); EventDeliveryFailure.requireSuccessful(response); response.data().requireMatches(envelope); - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - eventId, worker); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - String error = EventDeliveryFailure.diagnostic(exception, internalToken); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); boolean permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - int retries = failures + 1; - String status = permanent || policy.exhausted(retries) ? "DEAD" : "PENDING"; - LocalDateTime next = permanent || policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries)); - jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - status, retries, next, safe(error), eventId, worker); - log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, error); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.RETRY || result == Outcome.DEAD) { + return jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?", + result == Outcome.DEAD ? "DEAD" : "PENDING", failures + 1, next == null ? finished : next, safe(error), finished, eventId, worker); + } + return jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?", + finished, finished, eventId, worker); + }); } + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FINANCE, LocalDateTime.now()); } + private List candidates() { return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); } @@ -84,10 +107,12 @@ public class WalletResultOutboxRelay { for (Map row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at service.consume(tampered)).isInstanceOf(FinanceEventException.class) @@ -122,7 +121,7 @@ class WalletCommandEventServiceTest { void rejectsCustomerMismatchBeforeTouchingWallet() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), - envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(), + envelope.occurredAt(), "32", envelope.aggregateType(), envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); @@ -139,8 +138,7 @@ class WalletCommandEventServiceTest { "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); JsonNode payload = mapper.valueToTree(command); return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, - WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), - RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, + WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java index bd88b0dbd..9d27000cc 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -193,7 +193,7 @@ class WalletCommandTransactionTest { new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); var payload = mapper.valueToTree(command); return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, - Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "31", "WALLET_HOLD", "PO-1", 1, + Instant.now(), "31", "WALLET_HOLD", "PO-1", 1, null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java index acb6c036e..f26132b1f 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java @@ -8,9 +8,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.web.EventDeliveryMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; +import java.time.LocalDateTime; import java.util.UUID; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; @@ -23,15 +28,16 @@ class WalletResultOutboxLeaseTest { var jdbc = new JdbcTemplate(new DriverManagerDataSource( "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1, - Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + Instant.now(), "1", "WALLET_HOLD", "ORDER-1", 1L, null, "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))", event.eventId(), mapper.writeValueAsString(event)); var client = mock(WalletResultLastlegClient.class); - var relay = new WalletResultOutboxRelay(jdbc, mapper, client); + var relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client); ReflectionTestUtils.setField(relay, "internalToken", "token"); ReflectionTestUtils.setField(relay, "maxAttempts", 12); var firstEntered = new CountDownLatch(1); @@ -54,8 +60,12 @@ class WalletResultOutboxLeaseTest { Future first = pool.submit(() -> relay.deliver(event.eventId())); assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue(); String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); - // Simulate lease recovery while the original HTTP request has not returned. - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL"); + // The actual recovery path must finish the old audit and defer the new attempt. + jdbc.update("UPDATE fin_wallet_event_outbox SET locked_at=?", LocalDateTime.now().minusSeconds(61)); + ReflectionTestUtils.invokeMethod(relay, "recoverExpired"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED"); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now()); + jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1)); Future second = pool.submit(() -> relay.deliver(event.eventId())); assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue(); String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); @@ -64,10 +74,12 @@ class WalletResultOutboxLeaseTest { first.get(10, TimeUnit.SECONDS); assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING"); assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner); - assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); releaseSecond.countDown(); second.get(10, TimeUnit.SECONDS); assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, newOwner)).isEqualTo("APPLIED"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED"); } finally { releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow(); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java index dfa0b1046..299e4ed63 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java @@ -8,12 +8,16 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.web.EventDeliveryMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -28,17 +32,18 @@ class WalletResultRetryTest { jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); eventId = UUID.randomUUID().toString(); var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(), - RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "1", "WALLET_HOLD", "ORDER-1", 1L, null, "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); originalJson = mapper.writeValueAsString(event); jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)", eventId, originalJson, LocalDateTime.now().minusSeconds(1)); client = mock(WalletResultLastlegClient.class); - relay = new WalletResultOutboxRelay(jdbc, mapper, client); + relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client); ReflectionTestUtils.setField(relay, "internalToken", "token"); ReflectionTestUtils.setField(relay, "maxAttempts", 3); ReflectionTestUtils.setField(relay, "retryJitter", 0.0); @@ -66,6 +71,7 @@ class WalletResultRetryTest { @Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() { jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?", LocalDateTime.now().minusSeconds(61)); + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61)); relay.relay(); assertThat(status()).isEqualTo("DEAD"); assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); @@ -75,6 +81,7 @@ class WalletResultRetryTest { @Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() { jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?", LocalDateTime.now().minusSeconds(61)); + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61)); relay.relay(); assertThat(status()).isEqualTo("PENDING"); assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); @@ -82,5 +89,27 @@ class WalletResultRetryTest { verifyNoInteractions(client); } + @Test void auditStartFailurePreventsHttpAndRollsBackClaim() { + jdbc.execute("DROP TABLE event_delivery_attempt"); + assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("PENDING"); + verifyNoInteractions(client); + } + + @Test void successfulRemoteResponseWithFailedAuditCommitLeavesStartedLeaseForRecovery() throws Exception { + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var event = mapper.readValue(originalJson, EventEnvelope.class); + when(client.receive(anyString(), any())).thenAnswer(invocation -> { + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + return com.baiyue.common.core.api.ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + }); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("SENDING"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + verify(client).receive(anyString(), any()); + } + private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); } } diff --git a/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..ef3fcfb82 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 2239efda4..e9bea312c 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,6 +9,8 @@ ## 模块入口 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + 柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java new file mode 100644 index 000000000..1535c3e26 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/firstleg/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("fl_inbound_outbox", "id", "publish_status", Set.of(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java index 25686ee0c..7a991beb9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -10,7 +10,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; @@ -68,7 +67,7 @@ public class CarrierScheduleNodeOutboxService { long aggregateVersion = Math.max(0, version == null ? 0 : version); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, - fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, + fact.getActualAt().atZone(zone).toInstant(), "0", "CONTAINER_BOOKING", string(containerBookingId), aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, payloadNode, payloadHash).requireValid(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index e5ac78d78..bf8fb343b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -29,35 +29,42 @@ public class LoadingCompletedOutboxRepository { .orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId) .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() - .eq(InboundOutboxEntity::getId, id) - .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) - .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") - .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) - .or().le(InboundOutboxEntity::getNextRetryAt, now)) - .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") - .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) - .setSql("retry_count = retry_count + CASE WHEN publish_status='PROCESSING' THEN 1 ELSE 0 END") - .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) - .set(InboundOutboxEntity::getLockedAt, now)) == 1; + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) + .eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now)) + .set(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .set(InboundOutboxEntity::getLockedBy, worker).set(InboundOutboxEntity::getLockedAt, now)) == 1; + } + + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) + .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker) + .le(InboundOutboxEntity::getLockedAt, cutoff) + .set(InboundOutboxEntity::getPublishStatus, next == null ? "DEAD" : "PENDING") + .set(InboundOutboxEntity::getRetryCount, failures).set(InboundOutboxEntity::getNextRetryAt, next) + .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) + .set(InboundOutboxEntity::getLastError, truncate(error))); } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) + public int published(long id, String worker, LocalDateTime now) { return mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) + .set(InboundOutboxEntity::getNextRetryAt, null) .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) .set(InboundOutboxEntity::getLastError, null)); } - public void dead(long id, String worker, int failures, String error) { - mapper.update(null, claimedBase(id, worker) + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "DEAD") .set(InboundOutboxEntity::getRetryCount, failures) .set(InboundOutboxEntity::getNextRetryAt, null) .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) .set(InboundOutboxEntity::getLastError, truncate(error))); } - public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) + public int retry(long id, String worker, LocalDateTime retryAt, String error) { return mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING") .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index 30a5964d3..956272df5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -1,6 +1,12 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,10 +27,12 @@ import java.util.UUID; /** 至少一次投递国内头程业务事件;Lastleg按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j -public class LoadingCompletedOutboxRelay { +public class LoadingCompletedOutboxRelay implements EventDeliveryProducer { @Resource private LoadingCompletedOutboxRepository repository; @Resource private LastlegEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @@ -37,45 +45,63 @@ public class LoadingCompletedOutboxRelay { public void relay() { if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); - LocalDateTime now=LocalDateTime.now(); - LocalDateTime expiredLeaseBefore=now.minusSeconds(leaseSeconds); - for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { - String workerId = UUID.randomUUID().toString(); - now = LocalDateTime.now(); - expiredLeaseBefore = now.minusSeconds(leaseSeconds); - - if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; - InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); - if(row==null) continue; + LocalDateTime scan = LocalDateTime.now(); + for (InboundOutboxEntity candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(); + if ("PROCESSING".equals(candidate.getPublishStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } + String worker = UUID.randomUUID().toString(); + String type = Stream.FIRSTLEG.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.FIRSTLEG.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FIRSTLEG.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; + InboundOutboxEntity row = repository.claimed(candidate.getId(), worker); + if (row == null) continue; int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); if (policy.exhausted(failures)) { - repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; boolean requestStarted = false; try { - EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); - EventEnvelopeValidator.requireValid(event); + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); requestStarted = true; - var response=client.receive(internalToken,event); - requireAcknowledgement(event, response); - repository.published(row.getId(),workerId,LocalDateTime.now()); - } catch(Exception exception) { - boolean permanent = EventDeliveryFailure.isPermanent(exception) + var response = client.receive(internalToken, event); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); + } catch (Exception exception) { + permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - String error = EventDeliveryFailure.diagnostic(exception, internalToken); - if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); - else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), error); - log.warn("头程业务事件投递失败: eventType={}, eventKey={}, reason={}", - row.getEventType(), row.getEventKey(), error); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, next, error); + return repository.published(row.getId(), worker, finished); + }); } } - private void requireAcknowledgement(EventEnvelope event, - ApiResponse response) { - EventDeliveryFailure.requireSuccessful(response); - response.data().requireMatches(event); - } - + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FIRSTLEG, LocalDateTime.now()); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 4832d048a..93b11a592 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -11,7 +11,6 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV3; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; @@ -124,7 +123,7 @@ public class LoadingCompletedOutboxService { String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String traceId = EventIdentifiers.newUuid(); return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 3, - Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN", + Instant.now(), String.valueOf(accountId), "LOADING_PLAN", String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, traceId, eventKey, null, payloadNode, hash).requireValid(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index e57b3fae5..de19bb079 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -8,7 +8,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; @@ -83,9 +82,8 @@ public class OverseasContainerEventConsumer { if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) { throw new EventProtocolException("不支持的海外货柜事件"); } - if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN - || !"CONTAINER_BOOKING".equals(event.aggregateType())) { - throw new EventProtocolException("海外货柜事件区域或聚合类型无效"); + if (!"CONTAINER_BOOKING".equals(event.aggregateType())) { + throw new EventProtocolException("海外货柜事件聚合类型无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { throw new EventProtocolException("payloadHash 与事件负载不一致"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java index 3693a8715..cafd14e10 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -9,7 +9,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasDeliveryCompletedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -102,7 +101,6 @@ public class OverseasDeliveryCompletedConsumer { private void validate(EventEnvelope event) { event.requireValid(); if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 - || event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) { throw new EventProtocolException("不支持的海外派送完成事件"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java index 261a74004..8b98100b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java @@ -9,7 +9,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.entity.InboundHoldRecordEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -87,8 +86,7 @@ public class OverseasInboundOrderDetainedConsumer { private void validateEnvelope(EventEnvelope event) { event.requireValid(); if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - || event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS - || event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) { + || event.eventVersion() != 1 || !"INBOUND_ORDER".equals(event.aggregateType())) { throw new EventProtocolException("不支持的海外入仓单暂扣事件"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { diff --git a/firstleg/src/main/resources/application-dev.yml b/firstleg/src/main/resources/application-dev.yml deleted file mode 100644 index d0360bf48..000000000 --- a/firstleg/src/main/resources/application-dev.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FIRSTLEG_DB_USERNAME:root} - password: ${FIRSTLEG_DB_PASSWORD:123456} - -# 仅本地开发环境输出 MyBatis 执行 SQL、参数与结果摘要到 IDEA 控制台。 -mybatis-plus: - configuration: - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/firstleg/src/main/resources/application-test.yml b/firstleg/src/main/resources/application-test.yml deleted file mode 100644 index f78a7a9b3..000000000 --- a/firstleg/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${FIRSTLEG_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${FIRSTLEG_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 6033ef4fa..c2e6d256c 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -2,24 +2,24 @@ server: port: ${FIRSTLEG_PORT:9202} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: firstleg-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} servlet: multipart: max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} @@ -56,10 +56,8 @@ baiyue: max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 56fb62383..20b5196f8 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -1079,3 +1079,38 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku ( KEY idx_dropship_carton_sku_code (sku_code,carton_id), CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id) ) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实'; + +CREATE TABLE IF NOT EXISTS event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..5ba00e738 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java index 4ee884bc3..5a90cb026 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -30,9 +30,9 @@ class LoadingCompletedOutboxRepositoryTest { when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); - assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + assertThat(repository.claim(1L, "worker", now)).isTrue(); assertThat(wrapper.getValue().getSqlSegment()) - .contains("next_retry_at", "<=", "publish_status", "locked_at"); + .contains("next_retry_at", "<=", "publish_status").doesNotContain("locked_at"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java new file mode 100644 index 000000000..63cf7e5df --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java @@ -0,0 +1,57 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class OutboxLeaseDatabaseTest { + @Test void loadingCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE fl_inbound_outbox(id BIGINT PRIMARY KEY,tenant_id BIGINT,aggregate_type VARCHAR(64),aggregate_id BIGINT,event_version INT,aggregate_sequence INT,payload_hash VARCHAR(100),event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,publish_status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(InboundOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundOutboxMapper.class); + var repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO fl_inbound_outbox(id,event_type,publish_status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "FirstLegLoadingPlanCompleted", now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("UPDATE fl_inbound_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); + assertThat(repository.claim(1L, "new", now)).isFalse(); + var next = now.plusSeconds(10); + assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next, + () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fl_inbound_outbox", Integer.class)).isEqualTo(1); + assertThat(repository.claim(1L, "early", now)).isFalse(); + assertThat(journal.begin("new", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", next, + () -> repository.claim(1L, "new", next))).isTrue(); + assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, + () -> repository.dead(1L, "old", 12, "late"))).isFalse(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PROCESSING"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isTrue(); + assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PUBLISHED"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index 288886bb2..d11bacd22 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.client.LastlegEventClient; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; @@ -24,6 +23,11 @@ import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableExcept import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -35,11 +39,21 @@ class LoadingCompletedOutboxRelayTest { private ObjectMapper objectMapper; private LoadingCompletedOutboxRelay relay; + private EventDeliveryJournal journal; + @BeforeEach void setUp() { objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); relay = new LoadingCompletedOutboxRelay(); ReflectionTestUtils.setField(relay, "repository", repository); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + ReflectionTestUtils.setField(relay, "journal", journal); + lenient().when(repository.published(anyLong(), anyString(), any())).thenReturn(1); + lenient().when(repository.retry(anyLong(), anyString(), any(), anyString())).thenReturn(1); + lenient().when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1); ReflectionTestUtils.setField(relay, "client", client); ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); @@ -52,7 +66,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED))); @@ -68,7 +82,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenThrow(new IllegalStateException("failed internal-token\nremote")); @@ -84,7 +98,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow( new NoFallbackAvailableException("No fallback available.", @@ -102,7 +116,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure( ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456")); @@ -110,7 +124,7 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( - "IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, " + "EventDeliveryFailure: Consumer rejected event: code=S0503, message=bridge inbox unavailable, " + "requestId=request-123, traceId=trace-456")); verify(repository, never()).published(anyLong(), anyString(), any()); } @@ -120,7 +134,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), @@ -135,7 +149,7 @@ class LoadingCompletedOutboxRelayTest { InboundOutboxEntity row = row(event()); row.setRetryCount(11); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); relay.relay(); @@ -149,7 +163,7 @@ class LoadingCompletedOutboxRelayTest { InboundOutboxEntity row = row(event()); row.setRetryCount(12); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); relay.relay(); verifyNoInteractions(client); @@ -157,10 +171,24 @@ class LoadingCompletedOutboxRelayTest { verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); } + @Test + void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception { + var row = row(event()); + row.setPublishStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1); + journal.begin("old", "firstleg", row.getEventType(), "9001", null, "consumer", LocalDateTime.now().minusMinutes(2), () -> true); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.expire(eq(9001L), eq("old"), any(), eq(2), any(), anyString())).thenReturn(1); + relay.relay(); + verify(repository).expire(eq(9001L), eq("old"), any(), eq(2), argThat(next -> next.isAfter(LocalDateTime.now())), anyString()); + verify(repository, never()).claim(anyLong(), anyString(), any()); + verifyNoInteractions(client); + } + private InboundOutboxEntity row(EventEnvelope event) throws Exception { InboundOutboxEntity row = new InboundOutboxEntity(); row.setId(9001L); row.setEventKey(event.eventId()); + row.setEventType(event.eventType()); row.setPayloadJson(objectMapper.writeValueAsString(event)); return row; } @@ -170,7 +198,7 @@ class LoadingCompletedOutboxRelayTest { "LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null, "WH-1", "Warehouse", List.of())); return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, - Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1", + Instant.parse("2026-08-20T03:00:01Z"), "1", "LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index dbb9a5567..6878b6ee3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; @@ -83,7 +82,7 @@ class OverseasContainerEventConsumerTest { new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), - valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), + valid.occurredAt(), valid.tenantId(), valid.aggregateType(), valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); @@ -107,7 +106,7 @@ class OverseasContainerEventConsumerTest { var node = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, - Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", + Instant.parse("2026-08-20T02:31:00Z"), "1", "CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null, "trace-12345678", "correlation-12345678", null, node, hash); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java index 8fa0a26ba..215339e11 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -78,7 +78,7 @@ class OverseasDeliveryCompletedConsumerTest { "101", "IN-1", "201", "FL-201", "201", "ONT8", time); var node = json.valueToTree(payload); return new EventEnvelope("00000000-0000-4000-8000-000000000001", - EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, "88", "INBOUND_ORDER", "101", 2L, 1L, "00000000-0000-4000-8000-000000000099", "delivery-101", null, node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java index 945887023..3c57ff84b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundHoldRecordEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; @@ -82,8 +81,7 @@ class OverseasInboundOrderDetainedConsumerTest { "LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z")); var node = objectMapper.valueToTree(payload); return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version), - EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), - RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), "88", "INBOUND_ORDER", "1001", version, 1L, "00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); } diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index b6a5b83b3..d7ad899ca 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -10,7 +10,7 @@ - 应用:`BaiyueGatewayApplication.java` - Security/访问域:`config/GatewaySecurityConfig.java`、`AppScopeAuthorizationManager.java` - 会话/CORS/请求头:`config/RedisTokenSessionWebFilter.java`、`GatewayCorsConfiguration.java`、`filter/TrustedIdentityHeaderFilter.java`、`CorrelationIdWebFilter.java` -- 路由配置:`src/main/resources/application*.yml`、`../config/nacos/{local,cn}/gateway.yml` +- 路由配置:`src/main/resources/application*.yml`、`../config/nacos/test/gateway.yml` - 测试:`src/test/java/com/baiyue/gateway/`;`mvn -pl gateway -am test` ## 修改约束与分期 diff --git a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java index 96a594ea1..e5f04ed5e 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java @@ -20,6 +20,17 @@ public class RedisTokenSessionWebFilter implements WebFilter { @Resource private ReactiveStringRedisTemplate reactiveStringRedisTemplate; @Resource private GatewaySecurityProperties properties; @Resource private ObjectMapper objectMapper; + @org.springframework.beans.factory.annotation.Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") + private String validationUri; + @org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token}") + private String internalToken; + private final org.springframework.web.reactive.function.client.WebClient stateClient = + org.springframework.web.reactive.function.client.WebClient.builder() + .clientConnector(new org.springframework.http.client.reactive.ReactorClientHttpConnector( + reactor.netty.http.client.HttpClient.create() + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000) + .responseTimeout(java.time.Duration.ofSeconds(4)).followRedirect(false))) + .build(); @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { @@ -34,7 +45,19 @@ public class RedisTokenSessionWebFilter implements WebFilter { WebFilterChain chain) { String jwtId = jwt.getToken().getId(); if (jwtId == null || jwtId.isBlank()) return unauthorized(exchange); + Object userId = jwt.getToken().getClaims().get("user_id"); + if (userId == null) return unauthorized(exchange); return reactiveStringRedisTemplate.hasKey(properties.accessTokenKey(jwtId)) + .flatMap(active -> Boolean.TRUE.equals(active) + ? stateClient.post().uri(validationUri) + .header("X-Internal-Service-Token", internalToken) + .bodyValue(java.util.Map.of("jwtId", jwtId, "userId", userId)) + .retrieve().bodyToMono(com.fasterxml.jackson.databind.JsonNode.class) + .map(body -> body.path("code").isTextual() && "200".equals(body.path("code").asText()) + && body.path("data").isBoolean() && body.path("data").booleanValue()) + .defaultIfEmpty(false) + : Mono.just(false)) + .timeout(java.time.Duration.ofSeconds(4)) .onErrorReturn(false) .flatMap(active -> Boolean.TRUE.equals(active) ? chain.filter(exchange) : unauthorized(exchange)); diff --git a/gateway/src/main/resources/application-cn.yml b/gateway/src/main/resources/application-cn.yml deleted file mode 100644 index d958fc845..000000000 --- a/gateway/src/main/resources/application-cn.yml +++ /dev/null @@ -1,10 +0,0 @@ -spring: - config: - activate: - on-profile: cn - -baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:cn} - data-residency: ${DATA_RESIDENCY:CN} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index c7df24b36..94758a195 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} group: dev: cn test: cn @@ -14,27 +14,27 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true data: redis: - host: ${REDIS_HOST:127.0.0.1} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: ${REDIS_TIMEOUT:3s} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} gateway: discovery: locator: @@ -157,12 +157,6 @@ spring: - Path=/integration-service/v3/api-docs/** filters: - StripPrefix=1 - - id: event-bridge-openapi - uri: lb://event-bridge-service - predicates: - - Path=/event-bridge-service/v3/api-docs/** - filters: - - StripPrefix=1 - id: finance-openapi uri: lb://finance-service predicates: @@ -178,7 +172,7 @@ baiyue: allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} security: - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} @@ -203,7 +197,6 @@ baiyue: - /firstleg-service/v3/api-docs/** - /audit-service/v3/api-docs/** - /integration-service/v3/api-docs/** - - /event-bridge-service/v3/api-docs/** - /finance-service/v3/api-docs/** - /lastleg-service/v3/api-docs/** - /warehouse-overseas-service/v3/api-docs/** @@ -251,8 +244,6 @@ springdoc: url: /audit-service/v3/api-docs - name: integration url: /integration-service/v3/api-docs - - name: event-bridge - url: /event-bridge-service/v3/api-docs - name: finance url: /finance-service/v3/api-docs - name: lastleg diff --git a/gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java b/gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java new file mode 100644 index 000000000..006c34f9d --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java @@ -0,0 +1,58 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RedisTokenSessionWebFilterTest { + @Test void checksAuthorityAsynchronouslyAndFailsClosed() throws Exception { + var response = new AtomicReference<>("{\"code\":\"200\",\"data\":true}"); + var bearer = new AtomicReference(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/validate", exchange -> { + bearer.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + var filter = new RedisTokenSessionWebFilter(); + var redis = mock(ReactiveStringRedisTemplate.class); + when(redis.hasKey("test:access:jti")).thenReturn(Mono.just(true)); + ReflectionTestUtils.setField(filter, "reactiveStringRedisTemplate", redis); + ReflectionTestUtils.setField(filter, "properties", new GatewaySecurityProperties(null, "issuer", "aud", "test", List.of())); + ReflectionTestUtils.setField(filter, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(filter, "validationUri", "http://127.0.0.1:" + server.getAddress().getPort() + "/validate"); + ReflectionTestUtils.setField(filter, "internalToken", "internal"); + try { + for (boolean accepted : new boolean[]{true, false}) { + response.set("{\"code\":\"200\",\"data\":" + accepted + "}"); + var jwt = Jwt.withTokenValue("token").header("alg", "RS256").claim("jti", "jti").claim("user_id", 1L).build(); + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/orders")) + .mutate().principal(Mono.just(new JwtAuthenticationToken(jwt))).build(); + var invoked = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange, request -> { invoked.set(true); return Mono.empty(); })) + .expectComplete().verify(Duration.ofSeconds(5)); + assertThat(invoked.get()).isEqualTo(accepted); + if (!accepted) assertThat(exchange.getResponse().getStatusCode().value()).isEqualTo(401); + assertThat(bearer.get()).isNull(); + } + } finally { server.stop(0); } + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md index e8e8b4c46..d7663b79e 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -18,7 +18,7 @@ | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | -| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | +| 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -32,8 +32,8 @@ - 注册创建时可用同一手机号初始化公司联系电话与首位管理员登录手机号,但此后两者独立:企业资料更新不得探测 Auth 唯一性或修改任何成员账号;成员登录手机号修改必须检查 Auth 唯一性、同步账号并撤销该账号会话。企业审核不得把法人身份证恢复为必填资质。 - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 -- 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 -- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 +- 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、成员角色与角色/权限状态变化必须在同一业务事务内推进持久化 subject 版本,客户状态推进 tenant 版本。`SecurityVersionService` 强制 MANDATORY 事务;不能用提交前 Redis 删除或 afterCommit 回调代替持久化版本。 +- `AuthorizationQueryMapper.findSnapshot` 单条 SELECT 同时读取有效身份、角色权限、两类仓绑定、subject/tenant 版本,避免跨语句混合快照。客户 `DISABLED`/`ARCHIVED`、成员或用户停用/删除拒绝授权;建档审核状态继续允许登录完善资料,业务资格仍由对应服务校验。无角色/权限的有效主体保留空集合,不能因 LEFT JOIN 丢失身份。共享角色变更须枚举全部关联客户成员。 ## 全量验收导航 diff --git a/iam/pom.xml b/iam/pom.xml index 20770d35f..7e758f9f3 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -5,7 +5,6 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml iam-service - com.baiyue.sharedcommon-event-contract${project.version} com.baiyue.sharedbaiyue-common-audit${project.version} org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java deleted file mode 100644 index c68eac775..000000000 --- a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.baiyue.iam.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient", - url = "${baiyue.security-event.event-bridge-url:}") -public interface SecurityEventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 727c6991b..cdb94e201 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -42,6 +42,14 @@ public final class AuthorizationDtos { private Long permissionVersion; private Long overseasWarehouseId; private Long domesticWarehouseId; + private Long tenantVersion; + + public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion, Long overseasWarehouseId, Long domesticWarehouseId) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L); + } public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, String clientType, Set roles, Set permissions, diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java new file mode 100644 index 000000000..15bb73804 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java @@ -0,0 +1,14 @@ +package com.baiyue.iam.dto; + +import lombok.Data; + +/** Rows from one MVCC statement; all identity/authorization facts share one read view. */ +@Data +public class AuthorizationSnapshotRow { + private String roleCode; + private String permissionCode; + private Long permissionVersion; + private Long tenantVersion; + private Long overseasWarehouseId; + private Long domesticWarehouseId; +} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 2f83481d9..42a525c93 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -12,105 +12,27 @@ import java.util.List; @Mapper public interface AuthorizationQueryMapper { @Select(""" - SELECT COUNT(1) - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id = #{userId} - AND u.status = 'ACTIVE' AND u.deleted = 0 - AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} - AND m.status = 'ACTIVE' AND m.deleted = 0 - AND (m.tenant_id = 0 OR EXISTS ( - SELECT 1 FROM iam_tenant t WHERE t.id = m.tenant_id AND t.deleted = 0 - AND t.status IN ('ACTIVE', 'PROFILE_REQUIRED', 'PENDING', 'REJECTED') - )) + SELECT r.role_code AS roleCode, p.permission_code AS permissionCode, + COALESCE(sv.permission_version,0) AS permissionVersion, + COALESCE(tv.status_version,0) AS tenantVersion, + m.overseas_warehouse_id AS overseasWarehouseId, + m.domestic_warehouse_id AS domesticWarehouseId + FROM iam_user u + JOIN iam_membership m ON m.user_id=u.id + LEFT JOIN iam_tenant t ON t.id=m.tenant_id AND t.deleted=0 + LEFT JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id + LEFT JOIN iam_role r ON r.id=mr.role_id AND (r.tenant_id=0 OR r.tenant_id=m.tenant_id) + AND r.app_code=#{appCode} AND r.status='ACTIVE' AND r.deleted=0 + LEFT JOIN iam_role_permission rp ON rp.role_id=r.id + LEFT JOIN iam_permission p ON p.id=rp.permission_id AND (p.tenant_id=0 OR p.tenant_id=m.tenant_id) + AND p.app_code=#{appCode} AND p.status='ACTIVE' AND p.deleted=0 + LEFT JOIN iam_security_subject_version sv ON sv.auth_user_id=u.auth_user_id + LEFT JOIN iam_security_tenant_version tv ON tv.tenant_id=m.tenant_id + WHERE u.auth_user_id=#{userId} AND u.status='ACTIVE' AND u.deleted=0 + AND m.id=#{membershipId} AND m.tenant_id=#{tenantId} AND m.status='ACTIVE' AND m.deleted=0 + AND (m.tenant_id=0 OR t.status IN ('ACTIVE','PROFILE_REQUIRED','PENDING','REJECTED')) """) - int countActiveIdentity(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("membershipId") long membershipId); - - @Select(""" - SELECT DISTINCT r.role_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - WHERE u.auth_user_id = #{userId} - AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} - AND r.status = 'ACTIVE' AND r.deleted = 0 - ORDER BY r.role_code - """) - List findRoles(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("membershipId") long membershipId, - @Param("appCode") String appCode); - - @Select(""" - SELECT DISTINCT p.permission_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - JOIN iam_role_permission rp ON rp.role_id = r.id - JOIN iam_permission p ON p.id = rp.permission_id - WHERE u.auth_user_id = #{userId} - AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 - AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) - AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 - ORDER BY p.permission_code - """) - List findPermissions(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("membershipId") long membershipId, - @Param("appCode") String appCode); - - @Select(""" - SELECT DISTINCT r.role_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 - AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 - ORDER BY r.role_code - """) - List findRolesByIdentity(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("appCode") String appCode); - - @Select(""" - SELECT DISTINCT p.permission_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - JOIN iam_role_permission rp ON rp.role_id = r.id - JOIN iam_permission p ON p.id = rp.permission_id - WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 - AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 - AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) - AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 - ORDER BY p.permission_code - """) - List findPermissionsByIdentity(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("appCode") String appCode); - - @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") - long findPermissionVersion(@Param("userId") long userId); - - @Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") - Long findOverseasWarehouseId(@Param("tenantId") long tenantId, - @Param("membershipId") long membershipId); - - @Select("SELECT domestic_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") - Long findDomesticWarehouseId(@Param("tenantId") long tenantId, - @Param("membershipId") long membershipId); + List findSnapshot( + @Param("userId") long userId, @Param("tenantId") long tenantId, + @Param("membershipId") long membershipId, @Param("appCode") String appCode); } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java index e16dda80a..89d4d4059 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java @@ -19,7 +19,8 @@ public interface IamMembershipRoleMapper extends BaseMapper { FROM iam_membership_role mr JOIN iam_membership m ON m.id = mr.membership_id AND m.tenant_id = mr.tenant_id JOIN iam_user u ON u.id = m.user_id - WHERE mr.tenant_id = #{tenantId} + JOIN iam_role r ON r.id = mr.role_id + WHERE (#{tenantId} = 0 OR r.tenant_id = #{tenantId}) AND mr.role_id = #{roleId} AND m.deleted = 0 AND u.deleted = 0 diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index f1e524e2a..3affec32d 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -69,7 +69,7 @@ public class RbacRepository { public Set findRoleIdsByPermission(long tenantId, Long permissionId) { List rows = rolePermissionMapper.selectList( new LambdaQueryWrapper() - .eq(IamRolePermission::getTenantId, tenantId) + .eq(tenantId != 0, IamRolePermission::getTenantId, tenantId) .eq(IamRolePermission::getPermissionId, permissionId)); Set result = new LinkedHashSet<>(); rows.forEach(row -> result.add(row.getRoleId())); diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index 6bfca6f98..e41fa12f6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -24,7 +24,7 @@ public class AuthorizationSnapshotService { private AuthorizationQueryMapper mapper; - @Transactional(readOnly = true) + @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public SnapshotResponse load(SnapshotRequest request) { requireRequest(request); String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT); @@ -37,25 +37,24 @@ public class AuthorizationSnapshotService { if ("PLATFORM".equals(appCode) && request.getTenantId() != 0) { throw new BizException(ResultCode.FORBIDDEN, "平台账号必须属于平台租户"); } - if (mapper.countActiveIdentity(request.getUserId(), request.getTenantId(), request.getMembershipId()) == 0) { + var rows = mapper.findSnapshot(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode); + if (rows.isEmpty()) { throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效"); } - Set roles = normalizedSet(mapper.findRoles( - request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); - Set permissions = normalizedSet(mapper.findPermissions( - request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); + Set roles = normalizedSet(rows.stream().map(com.baiyue.iam.dto.AuthorizationSnapshotRow::getRoleCode).toList()); + Set permissions = normalizedSet(rows.stream().map(com.baiyue.iam.dto.AuthorizationSnapshotRow::getPermissionCode).toList()); + var identity = rows.get(0); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), - mapper.findPermissionVersion(request.getUserId()), - mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId()), - mapper.findDomesticWarehouseId(request.getTenantId(), request.getMembershipId())); + identity.getPermissionVersion(), identity.getOverseasWarehouseId(), + identity.getDomesticWarehouseId(), identity.getTenantVersion()); } /** * 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。 */ - @Transactional(readOnly = true) + @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Set currentPermissions(AuthenticatedUser user, String appCode) { if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) { throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整"); diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 43f79000e..0779a61c8 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -41,7 +41,7 @@ public class MemberService { @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -106,8 +106,7 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); - securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); - securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + securityVersions.subjectChanged(authId); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { @@ -146,6 +145,7 @@ public class MemberService { if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(request.roleIds()))) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); + securityVersions.subjectChanged(user.getAuthUserId()); } return response(tenantId, membership); } @@ -169,9 +169,7 @@ public class MemberService { membership.setStatus(request.status()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); - securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); - securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT", - "MEMBER_STATUS_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 2b2748c2b..de62ec41b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -43,7 +43,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; - @Resource private SecurityEventOutboxService securityEvents; + @Resource private SecurityVersionService securityVersions; @Resource private CustomerCodeRepository customerCodes; @Transactional @@ -82,7 +82,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); - securityEvents.tenantStatus(tenantId, tenant.getStatus()); + securityVersions.tenantChanged(tenantId); IamUser user = new IamUser(); user.setId(userId); @@ -114,8 +114,7 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); - securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); - securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + securityVersions.subjectChanged(request.authUserId()); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 8f94a889d..abc0e4e32 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -39,7 +39,7 @@ public class PlatformInternalUserService { @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -77,7 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); - publishInitialSecurityProjection(user); + initializeSecurityVersion(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -116,6 +116,7 @@ public class PlatformInternalUserService { repository.save(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); if (rolesChanged && !displayNameChanged) revokeSessions(user.getAuthUserId()); + if (rolesChanged) securityVersions.subjectChanged(user.getAuthUserId()); } catch (RuntimeException localFailure) { if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure); throw localFailure; @@ -146,9 +147,7 @@ public class PlatformInternalUserService { user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); - securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); - securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", - "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); @@ -178,8 +177,7 @@ public class PlatformInternalUserService { repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) { revokeSessions(user.getAuthUserId()); - securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", - "MEMBER_ROLES_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); @@ -204,14 +202,12 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); - publishInitialSecurityProjection(user); + initializeSecurityVersion(user); return response(membership, user, authAccount.username(), roleIds); } - private void publishInitialSecurityProjection(IamUser user) { - securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); - securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", - "INITIAL_AUTHORIZATION"); + private void initializeSecurityVersion(IamUser user) { + securityVersions.subjectChanged(user.getAuthUserId()); } private InternalUserResponse response(IamMembership membership) { diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 93f0dfe29..060a53a05 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -44,7 +44,7 @@ public class PlatformUserManagementService { @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -107,8 +107,7 @@ public class PlatformUserManagementService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); - securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); - securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); + securityVersions.subjectChanged(authUserId); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -170,8 +169,7 @@ public class PlatformUserManagementService { new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } if (rolesChanged || warehouseChanged || scopeChanged) { - securityEvents.permissionChanged(user.getAuthUserId(), newTenantId, - appCode(newTenantId), "PLATFORM_USER_AUTHORIZATION_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } } catch (RuntimeException localFailure) { compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount, @@ -204,10 +202,7 @@ public class PlatformUserManagementService { user.setStatus(request.status()); members.saveUser(user); members.save(membership); - securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), - appCode(membership.getTenantId()), request.status()); - securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(), - appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index 6f1669ee6..f30f728ce 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -33,7 +33,7 @@ public class RbacService { @Resource private RbacRepository repository; @Resource private MemberRepository memberRepository; @Resource private AuthAccountClient authClient; - @Resource private SecurityEventOutboxService securityEvents; + @Resource private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -186,6 +186,7 @@ public class RbacService { affectedAccounts.addAll(repository.findAssignedAuthUserIds(tenantId, roleId)); } revokeSessions(List.copyOf(affectedAccounts)); + affectedAccounts.forEach(id -> securityVersions.subjectChanged(id)); return permission; } @@ -240,7 +241,7 @@ public class RbacService { var user = memberRepository.findUser(membership.getUserId()); if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable(); revokeSessions(List.of(user.getAuthUserId())); - securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } } @@ -260,12 +261,13 @@ public class RbacService { repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds()); List affected = repository.findAssignedAuthUserIds(tenantId, request.roleId()); revokeSessions(affected); - affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode, - "ROLE_PERMISSIONS_CHANGED")); + affected.forEach(userId -> securityVersions.subjectChanged(userId)); } private void revokeAssignedSessions(long tenantId, Long roleId) { - revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId)); + List affected = repository.findAssignedAuthUserIds(tenantId, roleId); + revokeSessions(affected); + affected.forEach(id -> securityVersions.subjectChanged(id)); } private void revokeSessions(List accountIds) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java deleted file mode 100644 index de41c5d9d..000000000 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.common.event.RegionCode; -import com.baiyue.iam.client.SecurityEventBridgeClient; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */ -@Component -@Slf4j -public class SecurityEventOutboxRelay { - @Resource private JdbcTemplate jdbc; - @Resource private ObjectMapper objectMapper; - @Resource private SecurityEventBridgeClient client; - @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts; - private final String workerId = UUID.randomUUID().toString(); - - @Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}") - public void relay() { - recoverExpiredLeases(); - for (String eventId : candidates()) deliver(eventId); - } - - @Transactional - public void deliver(String eventId) { - LocalDateTime now = LocalDateTime.now(); - int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?," - + "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", - workerId, now.plusMinutes(2), now, eventId, now); - if (claimed != 1) return; - String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox " - + "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId); - try { - EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); - EventEnvelopeValidator.requireValid(envelope); - if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { - throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); - } - var response = client.receive(internalToken, envelope); - if (response == null || !response.successful() || response.data() == null - || !eventId.equals(response.data().eventId())) { - throw new IllegalStateException("事件桥未确认 IAM 安全事件"); - } - jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL," - + "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?", - LocalDateTime.now(), LocalDateTime.now(), eventId, workerId); - } catch (Exception exception) { - fail(eventId, exception.getMessage()); - log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage()); - } - } - - private List candidates() { - return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' " - + "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); - } - - private void recoverExpiredLeases() { - jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL," - + "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until= maxAttempts ? "DEAD" : "PENDING"; - long delay = Math.min(1800, 5L << Math.min(next, 8)); - jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?," - + "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?", - status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId); - } - - private String truncate(String value) { - String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); - return text.length() > 1000 ? text.substring(0, 1000) : text; - } -} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java deleted file mode 100644 index 6d5266658..000000000 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventIdentifiers; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.baiyue.iam.mapper.AuthorizationQueryMapper; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */ -@Service -public class SecurityEventOutboxService { - private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of( - "platform:overseas-warehouse:", "platform:overseas-business:", - "platform:overseas-master-data:", "merchant:warehouse:", "merchant:overseas-warehouse:", - "merchant:cross-border:", "platform:finance:", "lastleg:"); - @Resource private JdbcTemplate jdbc; - @Resource private ObjectMapper objectMapper; - @Resource private AuthorizationQueryMapper authorizationQueries; - - public void userStatus(long authUserId, long tenantId, String appCode, String status) { - long version = bump(authUserId); - write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version, - new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now())); - } - - public void tenantStatus(long tenantId, String status) { - jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) " - + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); - long version = jdbc.queryForObject( - "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); - String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; - write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, - new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); - } - - public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { - long version = bump(authUserId); - Set roles = Set.copyOf(authorizationQueries.findRolesByIdentity(authUserId, tenantId, appCode)); - Set authorities = authorizationQueries.findPermissionsByIdentity(authUserId, tenantId, appCode) - .stream().filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream() - .anyMatch(permission::startsWith)).collect(Collectors.toUnmodifiableSet()); - write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version, - new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, - Instant.now(), roles, authorities)); - } - - private long bump(long authUserId) { - jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) " - + "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); - return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", - Long.class, authUserId); - } - - private void write(String type, String aggregateType, String aggregateId, long tenantId, - long aggregateVersion, Object value) { - JsonNode payload = objectMapper.valueToTree(value); - String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); - String flowId = EventIdentifiers.newUuid(); - EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION, - Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType, - aggregateId, aggregateVersion, null, flowId, flowId, null, payload, hash).requireValid(); - try { - jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId, - objectMapper.writeValueAsString(envelope)); - } catch (Exception exception) { - throw new IllegalStateException("IAM 安全事件写入失败", exception); - } - } -} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java new file mode 100644 index 000000000..c8347b289 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java @@ -0,0 +1,24 @@ +package com.baiyue.iam.service; + +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Durable security epochs commit or roll back with their IAM mutation, with no projection relay. */ +@Service +@Transactional(propagation = Propagation.MANDATORY) +public class SecurityVersionService { + @Resource private JdbcTemplate jdbc; + + public void subjectChanged(long authUserId) { + jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); + } + + public void tenantChanged(long tenantId) { + jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 811857965..6c5324b5a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -44,7 +44,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -115,7 +115,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); - publishInitialSecurityProjection(authUserId, tenant.getId()); + initializeSecurityVersion(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -156,7 +156,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(), user.getPhone(), user.getDisplayName()); - publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); + initializeSecurityVersion(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -171,8 +171,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); - securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", - "MEMBER_ROLES_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } return ProvisioningResult.existing(); } @@ -232,9 +231,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } - private void publishInitialSecurityProjection(long authUserId, long tenantId) { - securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); - securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + private void initializeSecurityVersion(long authUserId, long tenantId) { + securityVersions.subjectChanged(authUserId); } private void compensate(long authUserId) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f6a849271..07c565a85 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -76,7 +76,7 @@ public class TenantService { @Resource private TenantAdministratorProvisioningService administratorProvisioning; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Transactional @BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT", @@ -194,7 +194,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); - securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); + securityVersions.tenantChanged(tenant.getId()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -302,7 +302,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); - securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); + securityVersions.tenantChanged(tenant.getId()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); @@ -424,7 +424,7 @@ public class TenantService { tenant.setStatus(request.status()); repository.saveTenant(tenant); revokeMerchantSessions(tenantId); - securityEvents.tenantStatus(tenantId, request.status()); + securityVersions.tenantChanged(tenantId); return response(tenant); } diff --git a/iam/src/main/resources/application-dev.yml b/iam/src/main/resources/application-dev.yml deleted file mode 100644 index de7343f90..000000000 --- a/iam/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${IAM_DB_USERNAME:root} - password: ${IAM_DB_PASSWORD:123456} diff --git a/iam/src/main/resources/application-test.yml b/iam/src/main/resources/application-test.yml deleted file mode 100644 index 9566a4810..000000000 --- a/iam/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${IAM_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${IAM_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index a23c9fe33..66e01f4bf 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -2,29 +2,28 @@ server: port: ${IAM_PORT:9201} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: iam-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue: security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} @@ -36,6 +35,5 @@ baiyue: auth: url: ${AUTH_SERVICE_URL:} security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql deleted file mode 100644 index ec4693cdb..000000000 --- a/iam/src/main/resources/db/security-event-outbox.sql +++ /dev/null @@ -1,35 +0,0 @@ -USE baiyue_iam; - -CREATE TABLE IF NOT EXISTS iam_security_subject_version ( - auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', - permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (auth_user_id) -) ENGINE=InnoDB COMMENT='安全主体权限版本'; - -CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( - tenant_id BIGINT NOT NULL COMMENT '客户ID', - status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (tenant_id) -) ENGINE=InnoDB COMMENT='客户状态事件版本'; - -CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( - event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', - event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型', - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识', - payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', - attempt_count INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数', - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', - lease_owner VARCHAR(128) NULL COMMENT '租约持有者', - lease_until DATETIME(3) NULL COMMENT '租约到期时间', - last_error VARCHAR(1000) NULL COMMENT '最近一次错误', - sent_at DATETIME(3) NULL COMMENT '发送成功时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (event_id), - KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), - KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) -) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱'; diff --git a/iam/src/main/resources/db/security-version.sql b/iam/src/main/resources/db/security-version.sql new file mode 100644 index 000000000..6c77203b8 --- /dev/null +++ b/iam/src/main/resources/db/security-version.sql @@ -0,0 +1,16 @@ +USE baiyue_iam; + +CREATE TABLE IF NOT EXISTS iam_security_subject_version ( + auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', + permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (auth_user_id) +) ENGINE=InnoDB COMMENT='安全主体权限版本'; + +CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( + tenant_id BIGINT NOT NULL COMMENT '客户ID', + status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (tenant_id) +) ENGINE=InnoDB COMMENT='客户状态持久化版本'; + diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 63643eabb..e893dee83 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -869,6 +869,37 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/authorization-snapshot: + post: + tags: [IAM Resources / IAM 资源] + summary: Read a consistent authoritative authorization snapshot + description: Internal service credential required; no public Gateway route. Identity, permissions, warehouse bindings and monotonic subject/customer versions are read by one MVCC SELECT statement, without cross-statement snapshot mixing. Auth fingerprints this state for login, refresh and every Access validation. + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userId, tenantId, membershipId, appCode] + properties: + userId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + membershipId: {type: integer, format: int64} + appCode: {type: string, enum: [PLATFORM, MERCHANT]} + responses: + '200': + description: Consistent snapshot or business error + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/AuthorizationSnapshot'} /internal/iam/merchant-registrations/{authUserId}: get: tags: [IAM Resources / IAM 资源] @@ -1747,3 +1778,18 @@ components: properties: roleId: {type: integer, format: int64} permissionIds: {type: array, items: {type: integer, format: int64}} + AuthorizationSnapshot: + type: object + required: [userId, tenantId, membershipId, appCode, clientType, roles, permissions, permissionVersion, tenantVersion] + properties: + userId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + membershipId: {type: integer, format: int64} + appCode: {type: string, enum: [PLATFORM, MERCHANT]} + clientType: {type: string} + roles: {type: array, items: {type: string}} + permissions: {type: array, items: {type: string}} + permissionVersion: {type: integer, format: int64, description: Persistent subject security epoch} + tenantVersion: {type: integer, format: int64, description: Persistent customer status epoch} + overseasWarehouseId: {type: integer, format: int64, nullable: true} + domesticWarehouseId: {type: integer, format: int64, nullable: true} diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java deleted file mode 100644 index 92692f92d..000000000 --- a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.baiyue.iam.client; - -import org.junit.jupiter.api.Test; -import org.springframework.cloud.openfeign.FeignClient; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class SecurityEventBridgeClientContractTest { - private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); - - @Test - void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { - FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); - - assertThat(annotation.name()).isEqualTo("event-bridge-service"); - assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); - } - - @Test - void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { - String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); - String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); - String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); - - assertThat(application).contains( - "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); - assertThat(nacos).contains( - "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); - assertThat(compose).contains( - "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java index d6d8047f4..d86f13c46 100644 --- a/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java +++ b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java @@ -12,26 +12,32 @@ class AuthorizationIdentitySqlTest { void disabledArchivedDeletedOrMissingCustomerCannotGetNewAuthorizationSnapshot() throws Exception { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE iam_user(id BIGINT, auth_user_id BIGINT, status VARCHAR(30), deleted INT)"); - jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT, overseas_warehouse_id BIGINT, domestic_warehouse_id BIGINT)"); jdbc.execute("CREATE TABLE iam_tenant(id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),role_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_role_permission(role_id BIGINT,permission_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_permission(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),permission_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT,permission_version BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT,status_version BIGINT)"); jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)"); - jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0,NULL,NULL)"); jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)"); - String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("countActiveIdentity", long.class, long.class, long.class) - .getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7"); + String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("findSnapshot", long.class, long.class, long.class, String.class) + .getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7").replace("#{appCode}", "'MERCHANT'"); for (String status : new String[]{"ACTIVE", "PROFILE_REQUIRED", "PENDING", "REJECTED"}) { jdbc.update("UPDATE iam_tenant SET status=?", status); - assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isEqualTo(1); + assertThat(jdbc.queryForList(sql).size()).as(status).isEqualTo(1); } for (String status : new String[]{"DISABLED", "ARCHIVED", "UNKNOWN"}) { jdbc.update("UPDATE iam_tenant SET status=?", status); - assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isZero(); + assertThat(jdbc.queryForList(sql).size()).as(status).isZero(); } jdbc.update("UPDATE iam_tenant SET status='ACTIVE', deleted=1"); - assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + assertThat(jdbc.queryForList(sql).size()).isZero(); jdbc.update("DELETE FROM iam_tenant"); - assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + assertThat(jdbc.queryForList(sql).size()).isZero(); jdbc.update("UPDATE iam_membership SET tenant_id=0"); - assertThat(jdbc.queryForObject(sql.replace("m.tenant_id = 7", "m.tenant_id = 0"), Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForList(sql.replace("m.tenant_id=7", "m.tenant_id=0")).size()).isEqualTo(1); } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java deleted file mode 100644 index b4336c35d..000000000 --- a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.baiyue.iam.repository; - -import org.junit.jupiter.api.Test; -import org.springframework.core.io.ClassPathResource; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class SecurityEventSqlContractTest { - @Test - void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception { - String sql = new ClassPathResource("db/security-event-outbox.sql") - .getContentAsString(StandardCharsets.UTF_8); - assertThat(sql) - .contains("iam_security_subject_version") - .contains("permission_version BIGINT NOT NULL DEFAULT 0") - .contains("iam_security_tenant_version") - .contains("status_version BIGINT NOT NULL DEFAULT 0") - .contains("iam_security_event_outbox") - .contains("payload_json JSON NOT NULL") - .contains("idx_security_outbox_publish") - .contains("PENDING", "SENDING", "SENT", "DEAD"); - } - - @Test - void cleanInitializationIncludesSecurityEventSchema() throws Exception { - Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); - Path migration = Files.exists(direct) - ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize(); - String sql = Files.readString(migration, StandardCharsets.UTF_8); - assertThat(sql) - .contains("CREATE TABLE iam_security_subject_version") - .contains("CREATE TABLE iam_security_tenant_version") - .contains("CREATE TABLE iam_security_event_outbox") - .contains("idx_security_outbox_publish", "idx_security_outbox_aggregate") - .contains("SELECT 'PASS' AS initialization_status") - .doesNotContain("assert_iam_security_event_schema", "CREATE TABLE IF NOT EXISTS iam_security_event"); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java new file mode 100644 index 000000000..2c59655fa --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityVersionSqlContractTest { + @Test void versionsRemainDurableWithoutProjectionOutbox() throws Exception { + String sql = new ClassPathResource("db/security-version.sql").getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains("iam_security_subject_version", "permission_version BIGINT NOT NULL DEFAULT 0", + "iam_security_tenant_version", "status_version BIGINT NOT NULL DEFAULT 0") + .doesNotContain("iam_security_event_outbox"); + } + + @Test void cleanInitializationIncludesVersionsWithoutRetiredProjectionTables() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path baseline = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + assertThat(Files.readString(baseline, StandardCharsets.UTF_8)) + .contains("CREATE TABLE iam_security_subject_version", "CREATE TABLE iam_security_tenant_version", + "SELECT 'PASS' AS initialization_status") + .doesNotContain("CREATE TABLE iam_security_event_outbox", "CREATE TABLE ll_security_event_inbox", "CREATE TABLE ll_security_projection"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 1c492f499..f8d83d00f 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -24,12 +24,7 @@ class AuthorizationSnapshotServiceTest { @Resource private AuthorizationSnapshotService service; @Test void loadsRolesAndPermissionsForActiveMerchantIdentity() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("ADMIN", "OPERATOR")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("order:read", "order:create")); - when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("ADMIN", "OPERATOR"), List.of("order:read", "order:create"))); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -51,36 +46,29 @@ class AuthorizationSnapshotServiceTest { @Test void rejectsInactiveMembership() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0); - - assertThatThrownBy(() -> service - .load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) - .isInstanceOfSatisfying(BizException.class, - exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); - verify(mapper).countActiveIdentity(42L, 7L, 9L); + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of()); + assertThatThrownBy(() -> service.load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + verify(mapper).findSnapshot(42L, 7L, 9L, "MERCHANT"); } @Test void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() { - when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR")); - when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")) - .thenReturn(List.of("platform:order:list")); + when(mapper.findSnapshot(42L, 0L, 9L, "PLATFORM")).thenReturn(rows(List.of("ORDER_OPERATOR"), List.of("platform:order:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); assertThat(result).containsExactly("platform:order:list"); - verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM"); + verify(mapper).findSnapshot(42L, 0L, 9L, "PLATFORM"); } @Test void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create")); + "merchant:warehouse:delivery:create"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); @@ -92,14 +80,12 @@ class AuthorizationSnapshotServiceTest { @Test void exposesCustomerOverseasBusinessPermissionsFromCurrentRoleAssignments() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( "merchant:cross-border:procurement:list", "merchant:cross-border:procurement:create", "merchant:cross-border:domestic-trucking:list", "merchant:cross-border:pallet-shipping:list", - "merchant:warehouse:inbound:list")); + "merchant:warehouse:inbound:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); @@ -114,11 +100,9 @@ class AuthorizationSnapshotServiceTest { @Test void exposesWalletPermissionsFromCurrentRoleAssignments() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( "merchant:finance:account:view", - "merchant:finance:recharge:create")); + "merchant:finance:recharge:create"))); var merchantPermissions = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); @@ -127,11 +111,9 @@ class AuthorizationSnapshotServiceTest { "merchant:finance:account:view", "merchant:finance:recharge:create"); - when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1); - when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN")); - when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of( + when(mapper.findSnapshot(51L, 0L, 19L, "PLATFORM")).thenReturn(rows(List.of("PLATFORM_ADMIN"), List.of( "platform:finance:recharge:review", - "platform:finance:customer-account:adjust")); + "platform:finance:customer-account:adjust"))); var platformPermissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L, "platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); @@ -143,11 +125,9 @@ class AuthorizationSnapshotServiceTest { @Test void exposesInboundHoldIndependentlyFromReadPermissions() { - when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1); - when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN")); - when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of( + when(mapper.findSnapshot(51L, 0L, 19L, "PLATFORM")).thenReturn(rows(List.of("PLATFORM_ADMIN"), List.of( "platform:firstleg:inbound:list", - "platform:firstleg:inbound:hold")); + "platform:firstleg:inbound:hold"))); var permissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L, "platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); @@ -168,9 +148,7 @@ class AuthorizationSnapshotServiceTest { @Test void shouldHandleCase01() { - when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); - when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null); + when(mapper.findSnapshot(42L, 0L, 9L, "PLATFORM")).thenReturn(rows(null, null)); var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); @@ -180,12 +158,10 @@ class AuthorizationSnapshotServiceTest { @Test void newLoginPermissionsDisappearWhileRoleDisabledAndReturnAfterReenable() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("ORDER_VIEWER"), List.of(), List.of("ORDER_VIEWER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("merchant:order:list", "merchant:order:view"), List.of(), - List.of("merchant:order:list", "merchant:order:view")); + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")) + .thenReturn(rows(List.of("ORDER_VIEWER"), List.of("merchant:order:list", "merchant:order:view")), + rows(List.of(), List.of()), + rows(List.of("ORDER_VIEWER"), List.of("merchant:order:list", "merchant:order:view"))); SnapshotRequest request = new SnapshotRequest(42L, 7L, 9L, "MERCHANT"); assertThat(service.load(request).getPermissions()) @@ -195,4 +171,16 @@ class AuthorizationSnapshotServiceTest { .containsExactlyInAnyOrder("merchant:order:list", "merchant:order:view"); } + private List rows(List roles, List permissions) { + var result = new java.util.ArrayList(); + List roleValues = roles == null || roles.isEmpty() ? java.util.Collections.singletonList(null) : roles; + List permissionValues = permissions == null || permissions.isEmpty() ? java.util.Collections.singletonList(null) : permissions; + for (String role : roleValues) for (String permission : permissionValues) { + var row = new com.baiyue.iam.dto.AuthorizationSnapshotRow(); + row.setRoleCode(role); row.setPermissionCode(permission); + row.setPermissionVersion(1L); row.setTenantVersion(1L); row.setOverseasWarehouseId(900L); + result.add(row); + } + return result; + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java new file mode 100644 index 000000000..bfad7d450 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java @@ -0,0 +1,90 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.Proxy; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.assertj.core.api.Assertions.*; + +/** Production SQL and transaction annotation, with a commit between the atomic SQL read and response assembly. */ +class AuthorizationSnapshotTransactionTest { + @Test void singleStatementSnapshotCannotMixIdentityPermissionsAndVersion() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE iam_user(id BIGINT,auth_user_id BIGINT,status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership(id BIGINT,user_id BIGINT,tenant_id BIGINT,status VARCHAR(20),deleted INT,overseas_warehouse_id BIGINT,domestic_warehouse_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_tenant(id BIGINT,status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),role_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_permission(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),permission_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_role_permission(role_id BIGINT,permission_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT PRIMARY KEY,permission_version BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT PRIMARY KEY,status_version BIGINT)"); + jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0,100,NULL)"); + jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_role VALUES(2,7,'MERCHANT','OPERATOR','ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership_role VALUES(9,7,2)"); + jdbc.update("INSERT INTO iam_permission VALUES(3,7,'MERCHANT','write','ACTIVE',0)"); + jdbc.update("INSERT INTO iam_role_permission VALUES(2,3)"); + jdbc.update("INSERT INTO iam_security_subject_version VALUES(42,1)"); + jdbc.update("INSERT INTO iam_security_tenant_version VALUES(7,1)"); + var factory = new SqlSessionFactoryBean(); factory.setDataSource(ds); + var configuration = new org.apache.ibatis.session.Configuration(); configuration.addMapper(AuthorizationQueryMapper.class); + configuration.addMapper(com.baiyue.iam.mapper.IamMembershipRoleMapper.class); + factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(AuthorizationQueryMapper.class); + var read = new CountDownLatch(1); var committed = new CountDownLatch(1); var pause = new AtomicBoolean(true); + AuthorizationQueryMapper controlled = (AuthorizationQueryMapper) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[]{AuthorizationQueryMapper.class}, (proxy, method, args) -> { + Object result = method.invoke(mapper, args); + if (method.getName().equals("findSnapshot") && pause.compareAndSet(true, false)) { + read.countDown(); if (!committed.await(5, TimeUnit.SECONDS)) throw new AssertionError("Commit barrier timed out"); + } + return result; + }); + var target = new AuthorizationSnapshotService(); ReflectionTestUtils.setField(target, "mapper", controlled); + var tx = new DataSourceTransactionManager(ds); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(tx, new AnnotationTransactionAttributeSource())); + var service = (AuthorizationSnapshotService) proxy.getProxy(); + var executor = Executors.newSingleThreadExecutor(); + try { + var previous = executor.submit(() -> service.load(new SnapshotRequest(42L,7L,9L,"MERCHANT"))); + assertThat(read.await(5, TimeUnit.SECONDS)).isTrue(); + new TransactionTemplate(tx).executeWithoutResult(status -> { + jdbc.update("UPDATE iam_permission SET permission_code='read' WHERE id=3"); + jdbc.update("UPDATE iam_security_subject_version SET permission_version=2 WHERE auth_user_id=42"); + jdbc.update("UPDATE iam_membership SET overseas_warehouse_id=200 WHERE id=9"); + }); + committed.countDown(); + var old = previous.get(5, TimeUnit.SECONDS); + assertThat(old.getPermissions()).containsExactly("write"); + assertThat(old.getPermissionVersion()).isEqualTo(1); assertThat(old.getOverseasWarehouseId()).isEqualTo(100); + var fresh = service.load(new SnapshotRequest(42L,7L,9L,"MERCHANT")); + assertThat(fresh.getPermissions()).containsExactly("read"); + assertThat(fresh.getPermissionVersion()).isEqualTo(2); assertThat(fresh.getOverseasWarehouseId()).isEqualTo(200); + // Shared platform role: enumerate both customers, not just membership tenant 0. + jdbc.update("INSERT INTO iam_user VALUES(2,43,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(10,2,8,'ACTIVE',0,NULL,NULL)"); + jdbc.update("INSERT INTO iam_role VALUES(5,0,'MERCHANT','SHARED','ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership_role VALUES(9,7,5),(10,8,5)"); + var assignments = new SqlSessionTemplate(factory.getObject()) + .getMapper(com.baiyue.iam.mapper.IamMembershipRoleMapper.class); + assertThat(assignments.findAssignedAuthUserIds(0, 5L)).containsExactly(42L, 43L); + assertThat(assignments.findAssignedAuthUserIds(7, 5L)).isEmpty(); + } finally { committed.countDown(); executor.shutdownNow(); } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index b190038a9..cdd56136a 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -35,7 +35,7 @@ class MemberServiceTest { @MockBean private TenantRepository tenantRepository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private MemberService service; private IamMembership member; private IamUser user; @@ -106,8 +106,7 @@ class MemberServiceTest { assertEquals("DISABLED", result.status()); verify(repository).save(member); verify(repository).saveUser(user); - verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED"); - verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED"); + verify(securityVersions).subjectChanged(40L); } @Test void shouldHandleCase05() { @@ -156,8 +155,7 @@ class MemberServiceTest { verify(auth).create(eq("internal-token"), account.capture()); long authUserId = account.getValue().accountId(); verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); - verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(authUserId); } @Test void shouldHandleCase08() { diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 339dc050c..488e8bb78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -44,7 +44,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @MockBean private CustomerCodeRepository customerCodes; @Resource private MerchantOnboardingService service; @@ -78,10 +78,8 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); - verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); - verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(securityVersions).tenantChanged(result.tenantId()); + verify(securityVersions).subjectChanged(9001L); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals("PENDING", result.dimensionInitializationStatus()); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index ede6e98a7..81d89c307 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -30,7 +30,7 @@ class PlatformInternalUserServiceTest { @MockBean private MemberRepository repository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private PlatformInternalUserService service; private IamMembership membership; private IamUser user; @@ -86,8 +86,7 @@ class PlatformInternalUserServiceTest { verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); long authUserId = captor.getValue().accountId(); - verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); - verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(authUserId); } @Test @@ -103,8 +102,7 @@ class PlatformInternalUserServiceTest { service.create(request); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); - verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(10L); verify(auth, never()).create(anyString(), any()); } @@ -116,7 +114,7 @@ class PlatformInternalUserServiceTest { service.create(request); - verifyNoInteractions(securityEvents); + verifyNoInteractions(securityVersions); verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @@ -150,9 +148,7 @@ class PlatformInternalUserServiceTest { new ChangeInternalUserStatusRequest("DISABLED"), 99L); assertEquals("DISABLED", result.status()); - verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED"); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index f9e96fda5..359125dc4 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -33,7 +33,7 @@ class PlatformUserManagementServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private PlatformUserManagementService service; private IamRole platformRole; private IamMembership membership; @@ -107,8 +107,7 @@ class PlatformUserManagementServiceTest { verify(auth).create(eq("internal-token"), account.capture()); long authUserId = account.getValue().accountId(); verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); - verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); - verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(authUserId); } @Test @@ -159,8 +158,7 @@ class PlatformUserManagementServiceTest { assertEquals(900L, membership.getOverseasWarehouseId()); assertEquals(900L, response.overseasWarehouseId()); verify(auth).revokeSessions(eq("internal-token"), any()); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_USER_AUTHORIZATION_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test @@ -197,8 +195,7 @@ class PlatformUserManagementServiceTest { assertEquals(901L, response.overseasWarehouseId()); verify(auth).revokeSessions(eq("internal-token"), eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_USER_AUTHORIZATION_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test @@ -225,8 +222,7 @@ class PlatformUserManagementServiceTest { assertNull(response.overseasWarehouseId()); verify(auth).revokeSessions(eq("internal-token"), eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_USER_AUTHORIZATION_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test @@ -246,8 +242,7 @@ class PlatformUserManagementServiceTest { UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L); assertEquals("ACTIVE", result.status()); - verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 9854bc23e..694286efb 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -40,7 +40,7 @@ class RbacServiceTest { @MockBean private RbacRepository repository; @MockBean private MemberRepository memberRepository; @MockBean private AuthAccountClient authClient; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private RbacService service; @Test @@ -338,6 +338,8 @@ class RbacServiceTest { verify(authClient, times(2)).revokeSessions(eq("test-internal-token"), captor.capture()); assertEquals(Set.of(501L, 502L), captor.getAllValues().get(0).accountIds()); assertEquals(Set.of(501L, 502L), captor.getAllValues().get(1).accountIds()); + verify(securityVersions, times(2)).subjectChanged(501L); + verify(securityVersions, times(2)).subjectChanged(502L); } @Test @@ -368,6 +370,7 @@ class RbacServiceTest { verify(authClient).revokeSessions(eq("test-internal-token"), eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L, 502L, 503L)))); + for (long id : new long[]{501, 502, 503}) verify(securityVersions).subjectChanged(id); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java deleted file mode 100644 index 9ceef4efc..000000000 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.baiyue.iam.client.SecurityEventBridgeClient; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class SecurityEventOutboxRelayTest { - @Mock private JdbcTemplate jdbc; - @Mock private SecurityEventBridgeClient client; - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - private SecurityEventOutboxRelay relay; - - @BeforeEach - void setUp() { - relay = new SecurityEventOutboxRelay(); - ReflectionTestUtils.setField(relay, "jdbc", jdbc); - ReflectionTestUtils.setField(relay, "client", client); - ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); - ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); - ReflectionTestUtils.setField(relay, "maxAttempts", 12); - } - - @Test - void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { - EventEnvelope envelope = envelope(RegionCode.OVERSEAS); - when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); - when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) - .thenReturn(objectMapper.writeValueAsString(envelope)); - when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( - new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); - - relay.deliver(envelope.eventId()); - - ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); - verify(client).receive(eq("internal-token"), sent.capture()); - assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); - assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); - verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); - } - - @Test - void nonOverseasTargetNeverReachesTheBridge() throws Exception { - EventEnvelope envelope = envelope(RegionCode.CN); - when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); - when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) - .thenReturn(objectMapper.writeValueAsString(envelope)); - when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); - - relay.deliver(envelope.eventId()); - - verify(client, never()).receive(anyString(), any(EventEnvelope.class)); - verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), - eq(envelope.eventId()), anyString()); - } - - private EventEnvelope envelope(RegionCode target) { - var value = new IamSecurityEventsV1.PermissionVersionChanged( - 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"), - java.util.Set.of("PLATFORM_ADMIN"), - java.util.Set.of("platform:overseas-warehouse:last-mile-order:list")); - var payload = objectMapper.valueToTree(value); - return new EventEnvelope("00000000-0000-4000-8000-000000000081", - EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, - Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", - "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", - null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java deleted file mode 100644 index b7e7f89c9..000000000 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.iam.mapper.AuthorizationQueryMapper; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class SecurityEventOutboxServiceTest { - @Mock JdbcTemplate jdbc; - @Mock AuthorizationQueryMapper authorizationQueries; - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - private SecurityEventOutboxService service; - - @BeforeEach - void setUp() { - service = new SecurityEventOutboxService(); - ReflectionTestUtils.setField(service, "jdbc", jdbc); - ReflectionTestUtils.setField(service, "objectMapper", objectMapper); - ReflectionTestUtils.setField(service, "authorizationQueries", authorizationQueries); - } - - @Test - void userStatusWritesAValidEnvelopeWithCorrelationId() throws Exception { - when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(1L); - - service.userStatus(101L, 0L, "PLATFORM", "DISABLED"); - - ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); - EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); - assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); - } - - @ParameterizedTest - @CsvSource({ - "ACTIVE,ACTIVE", - "PROFILE_REQUIRED,DISABLED", - "PENDING,DISABLED", - "REJECTED,DISABLED", - "DISABLED,DISABLED", - "ARCHIVED,DISABLED" - }) - void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, - String expectedSecurityStatus) throws Exception { - when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); - - service.tenantStatus(10001L, businessStatus); - - ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); - EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); - IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( - envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); - assertThat(payload.status()).isEqualTo(expectedSecurityStatus); - assertThat(envelope.aggregateVersion()).isEqualTo(3L); - } - - @Test - void permissionEventCarriesOnlyTheOverseasAuthorizationSnapshot() throws Exception { - when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(7L); - when(authorizationQueries.findRolesByIdentity(101L, 0L, "ADMIN")) - .thenReturn(List.of("PLATFORM_ADMIN")); - when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN")) - .thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view", - "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view")); - - service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED"); - - ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); - EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); - IamSecurityEventsV1.PermissionVersionChanged payload = objectMapper.treeToValue( - envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); - assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN"); - assertThat(payload.authorities()).containsExactlyInAnyOrder( - "platform:overseas-warehouse:orders:view", - "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view"); - assertThat(payload.authorities()).doesNotContain("platform:iam:users:view"); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java new file mode 100644 index 000000000..40df8cceb --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.service; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class SecurityVersionTransactionTest { + @Test void concurrentVersionsAreDurableAndRollbackWithMutation() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT PRIMARY KEY,permission_version BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT PRIMARY KEY,status_version BIGINT)"); + var manager = new DataSourceTransactionManager(ds); + var target = new SecurityVersionService(); ReflectionTestUtils.setField(target, "jdbc", jdbc); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + var service = (SecurityVersionService) proxy.getProxy(); + assertThatThrownBy(() -> service.subjectChanged(42)).isInstanceOf(org.springframework.transaction.IllegalTransactionStateException.class); + new TransactionTemplate(manager).executeWithoutResult(tx -> { service.subjectChanged(42); service.tenantChanged(7); }); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(4); + try { + var futures = new ArrayList>(); + for (int i=0; i<8; i++) futures.add(executor.submit(() -> { + try { if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("Start barrier timed out"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); } + new TransactionTemplate(manager).executeWithoutResult(tx -> service.subjectChanged(42)); + })); + start.countDown(); for (var future : futures) future.get(10, TimeUnit.SECONDS); + } finally { executor.shutdownNow(); } + new TransactionTemplate(manager).executeWithoutResult(tx -> { + service.subjectChanged(42); service.tenantChanged(7); tx.setRollbackOnly(); + }); + assertThat(jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=42", Long.class)).isEqualTo(9); + assertThat(jdbc.queryForObject("SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=7", Long.class)).isEqualTo(1); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index bb874cce6..f52624c7c 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -44,7 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -104,9 +104,7 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); - verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(account.getValue().accountId()); } @Test @@ -141,7 +139,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); - verifyNoInteractions(securityEvents); + verifyNoInteractions(securityVersions); } @Test @@ -160,8 +158,7 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); - verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", - "MEMBER_ROLES_CHANGED"); + verify(securityVersions).subjectChanged(user.getAuthUserId()); } @Test @@ -199,9 +196,7 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); - verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(user.getAuthUserId()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index aa1f23fd3..eceb1b97b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -30,7 +30,7 @@ class TenantCustomerExportTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private TenantAdministratorProvisioningService administratorProvisioning; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private TenantService service; @Test void returnsUnmaskedCustomerRowsAndAdvancingCursor() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 28eb7d4de..9288fe3de 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -57,7 +57,7 @@ class TenantServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient authClient; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @SpyBean private TenantAdministratorProvisioningService provisioning; @@ -196,13 +196,13 @@ class TenantServiceTest { when(authClient.revokeSessions("internal-token", request)).thenReturn(null); assertThrows(BizException.class, () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"))); - verify(securityEvents, never()).tenantStatus(org.mockito.ArgumentMatchers.anyLong(), any()); + verify(securityVersions, never()).tenantChanged(org.mockito.ArgumentMatchers.anyLong()); when(authClient.revokeSessions("internal-token", request)) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(null)); assertEquals("DISABLED", service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")).status()); - verify(securityEvents).tenantStatus(10001L, "DISABLED"); + verify(securityVersions).tenantChanged(10001L); } @Test @@ -357,7 +357,7 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); - verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); + verify(securityVersions).tenantChanged(created.tenant().id()); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @@ -474,7 +474,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); - verify(securityEvents).tenantStatus(10001L, "ACTIVE"); + verify(securityVersions).tenantChanged(10001L); } @Test diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index e880358b8..8347661ef 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -12,7 +12,7 @@ - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` - 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md` -- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` +- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/test/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` ## 修改约束与分期 diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index f6d42fe07..7916af7b8 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: integration-service servlet: @@ -16,20 +16,20 @@ spring: - com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} baiyue: security: diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ac68700b8..ba6124ce2 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,6 +9,8 @@ ## 快速索引 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/lastleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许卸柜、派送、暂扣、资金指令与费用事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + 本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。 @@ -26,7 +28,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java` | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java` | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| IAM 海外安全投影与实时校验 | `security/controller/LastlegSecurityController.java` | +| 统一会话校验 | 复用 `../common-security/` Redis会话与Auth权威状态校验;Lastleg不维护授权投影 | | 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` | @@ -69,7 +71,7 @@ - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 - 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 -- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 ## 数据库表索引与注释约束 @@ -83,7 +85,6 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | -| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -99,9 +100,7 @@ - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 -- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 -- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 -- 开发/测试空库基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;AUTHORIZATION 必须携带当前 `PLATFORM_ADMIN` 角色及按 IAM 跨区前缀筛选的完整权限快照,统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,后续 IAM version>=1 事件单调覆盖。不得扩大为其他历史账号运行时补齐或缺失投影默认放行。 +- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 @@ -139,7 +138,6 @@ - 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 - 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 -- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 7ea635ea1..a02edfb28 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -31,7 +31,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | +| 统一认证与撤销 | 复用 common-security 的Redis会话及Auth/IAM权威指纹校验,故障拒绝访问;Lastleg不保存独立授权投影 | | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java new file mode 100644 index 000000000..bf1ab9909 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/lastleg/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 2fc682315..9d6dc5565 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasDeliveryCompletedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; @@ -43,7 +42,7 @@ public class DeliveryCompletedOutboxService { // acknowledgement event. Customer/local overseas orders can still carry // a local sourceInboundId, but deliberately have no domestic sourceOrderId. if (StringUtils.hasText(order.getSourceRegion()) - && !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue; + && !"FIRSTLEG".equals(order.getSourceRegion())) continue; // Relabel and warehouse-transfer deliveries are created and completed wholly // in the overseas last-leg domain. They do not have a domestic source order // to acknowledge, so publishing the cross-region event would make receipt @@ -73,7 +72,7 @@ public class DeliveryCompletedOutboxService { order.getFbaWarehouseCode(), completedAt); JsonNode node = objectMapper.valueToTree(payload); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L, EventIdentifiers.newUuid(), eventKey, EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java index d37fab88b..113830613 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -24,33 +24,41 @@ public class OverseasFeeOutboxRepository { .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) .orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit)); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") - .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) - .or(processing -> - processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) - .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") - .set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker) - .set(LastlegEventOutbox::getLockedAt, now)) == 1; + .eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) + .set(LastlegEventOutbox::getStatus, "PROCESSING") + .set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .le(LastlegEventOutbox::getLockedAt, cutoff) + .set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING") + .set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); } public LastlegEventOutbox claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper().eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { - mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) + public int published(long id, String worker, LocalDateTime now) { + return mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); } - public void dead(long id, String worker, int failures, String error) { - mapper.update(null, claimed(id, worker, "DEAD") + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimed(id, worker, "DEAD") .set(LastlegEventOutbox::getRetryCount, failures) .set(LastlegEventOutbox::getNextRetryAt, null) .set(LastlegEventOutbox::getLastError, truncate(error))); } - public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) { - mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) + public int retry(long id, String worker, int retryCount, LocalDateTime next, String error) { + return mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) .set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error))); } private LambdaUpdateWrapper claimed(long id, String worker, String status) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java index 8ab22ae04..045e75741 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -1,5 +1,11 @@ package com.baiyue.lastleg.financefee.service; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -18,10 +24,12 @@ import java.time.ZoneOffset; import java.util.UUID; @Component -public class OverseasFeeOutboxRelay { +public class OverseasFeeOutboxRelay implements EventDeliveryProducer { @Resource private OverseasFeeOutboxRepository repository; @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token:}") private String token; @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; @@ -35,35 +43,63 @@ public class OverseasFeeOutboxRelay { public void relay() { if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); - LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); - for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) { + LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC); + for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(ZoneOffset.UTC); + if ("PROCESSING".equals(candidate.getStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } String worker = UUID.randomUUID().toString(); - now = LocalDateTime.now(ZoneOffset.UTC); - - if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) continue; + String type = Stream.FEE.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FEE.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); if (policy.exhausted(failures)) { - repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(ZoneOffset.UTC), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; boolean requestStarted = false; try { - EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); requestStarted = true; - var response = client.receive(token, envelope); + var response = client.receive(token, event); EventDeliveryFailure.requireSuccessful(response); - response.data().requireMatches(envelope); - repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - String error = EventDeliveryFailure.diagnostic(exception, token); - boolean permanent = EventDeliveryFailure.isPermanent(exception) + permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - int retries = failures + 1; - if (permanent || policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, error); - else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), error); + diagnostic = EventDeliveryFailure.diagnostic(exception, token); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(ZoneOffset.UTC); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, retries, next, error); + return repository.published(row.getId(), worker, finished); + }); } } + + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FEE, LocalDateTime.now(ZoneOffset.UTC)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index 349dab9e1..98296a317 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -37,7 +36,7 @@ public class OverseasFeeOutboxService { + source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action; if (mapper.findByEventKey(eventKey) != null) return; EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()), source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index 5749d7013..5b5e9e4f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -1,5 +1,11 @@ package com.baiyue.lastleg.inboundplan.pda.service; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -22,10 +28,12 @@ import java.util.UUID; /** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */ @Component @Slf4j -public class InboundUnloadCompletedOutboxRelay { +public class InboundUnloadCompletedOutboxRelay implements EventDeliveryProducer { @Resource private UnloadCompletedOutboxRepository repository; @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @@ -38,44 +46,63 @@ public class InboundUnloadCompletedOutboxRelay { public void relay() { if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); - LocalDateTime now = LocalDateTime.now(); - LocalDateTime expiredLeaseBefore = now.minusSeconds(leaseSeconds); - for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { - String workerId = UUID.randomUUID().toString(); - now = LocalDateTime.now(); - expiredLeaseBefore = now.minusSeconds(leaseSeconds); - - if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; - LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); + LocalDateTime scan = LocalDateTime.now(); + for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(); + if ("PROCESSING".equals(candidate.getStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } + String worker = UUID.randomUUID().toString(); + String type = Stream.LASTLEG.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.LASTLEG.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.LASTLEG.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; + LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); if (policy.exhausted(failures)) { - repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; boolean requestStarted = false; try { - EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); - EventEnvelopeValidator.requireValid(event); + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); requestStarted = true; var response = client.receive(internalToken, event); - requireAcknowledgement(event, response); - repository.published(row.getId(), workerId, LocalDateTime.now()); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - String error = EventDeliveryFailure.diagnostic(exception, internalToken); - boolean permanent = EventDeliveryFailure.isPermanent(exception) + permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); - else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), - error); - log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), error); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, next, error); + return repository.published(row.getId(), worker, finished); + }); } } - private void requireAcknowledgement(EventEnvelope event, - com.baiyue.common.core.api.ApiResponse response) { - EventDeliveryFailure.requireSuccessful(response); - response.data().requireMatches(event); - } + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.LASTLEG, LocalDateTime.now()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 60de90bae..db7c5f342 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; @@ -78,7 +77,7 @@ public class InboundUnloadCompletedOutboxService { JsonNode payloadNode = objectMapper.valueToTree(payload); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING", + 1, Instant.now(), "0", "CONTAINER_BOOKING", sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index bbce4b4b1..9858946d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -32,21 +32,25 @@ public class UnloadCompletedOutboxRepository { .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() - .eq(LastlegEventOutbox::getId, id) - .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") - .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) - .or().le(LastlegEventOutbox::getNextRetryAt, now)) - .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") - .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) - .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") + .eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .set(LastlegEventOutbox::getStatus, "PROCESSING") - .set(LastlegEventOutbox::getLockedBy, worker) - .set(LastlegEventOutbox::getLockedAt, now)) == 1; + .set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1; } + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .le(LastlegEventOutbox::getLockedAt, cutoff) + .set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING") + .set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public LastlegEventOutbox claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(LastlegEventOutbox::getId, id) @@ -55,8 +59,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { - mapper.update(null, claimedBase(id, worker) + public int published(long id, String worker, LocalDateTime now) { + return mapper.update(null, claimedBase(id, worker) .set(LastlegEventOutbox::getStatus, "PUBLISHED") .set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null) @@ -65,16 +69,16 @@ public class UnloadCompletedOutboxRepository { .set(LastlegEventOutbox::getLastError, null)); } - public void dead(long id, String worker, int failures, String error) { - mapper.update(null, claimedBase(id, worker) + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimedBase(id, worker) .set(LastlegEventOutbox::getStatus, "DEAD") .set(LastlegEventOutbox::getRetryCount, failures) .set(LastlegEventOutbox::getNextRetryAt, null) .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) .set(LastlegEventOutbox::getLastError, truncate(error))); } - public void retry(long id, String worker, LocalDateTime retryAt, String error) { - mapper.update(null, claimedBase(id, worker) + public int retry(long id, String worker, LocalDateTime retryAt, String error) { + return mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1") .set(LastlegEventOutbox::getStatus, "PENDING") .set(LastlegEventOutbox::getNextRetryAt, retryAt) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java deleted file mode 100644 index 6b3cb5093..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.lastleg.security.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.lastleg.security.InternalServiceTokenGuard; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; -import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService; -import com.baiyue.lastleg.security.service.LastlegSecurityValidationService; -import io.swagger.v3.oas.annotations.Hidden; -import jakarta.validation.Valid; -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; - -import java.time.Instant; - -@Hidden -@RestController -@RequestMapping("/internal/lastleg/security/v1") -public class LastlegSecurityController { - private final InternalServiceTokenGuard tokenGuard; - private final LastlegSecurityProjectionService projections; - private final LastlegSecurityValidationService validations; - - public LastlegSecurityController(InternalServiceTokenGuard tokenGuard, - LastlegSecurityProjectionService projections, - LastlegSecurityValidationService validations) { - this.tokenGuard = tokenGuard; - this.projections = projections; - this.validations = validations; - } - - @PostMapping("/events") - public ApiResponse receive( - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @RequestBody EventEnvelope envelope) { - tokenGuard.verify(token); - var result = projections.consume(envelope); - return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now())); - } - - @PostMapping("/validate") - public ApiResponse validate( - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @Valid @RequestBody ValidationRequest request) { - tokenGuard.verify(token); - return ApiResponse.success(validations.validate(request)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java deleted file mode 100644 index b7a7e72ae..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.baiyue.lastleg.security.dto; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; - -import java.time.Instant; -import java.util.Set; - -public final class LastlegSecurityDtos { - private LastlegSecurityDtos() { } - - public record Acknowledgement(String eventId, String result, Instant receivedAt) { } - - public record ValidationRequest(@NotNull @Min(1) Long userId, - @NotNull @Min(0) Long tenantId, - @NotNull @Min(0) Long permissionVersion) { } - - public record ValidationResponse(boolean valid, FailureReason failureReason, - Set roles, Set authorities) { - public ValidationResponse { - roles = roles == null ? Set.of() : Set.copyOf(roles); - authorities = authorities == null ? Set.of() : Set.copyOf(authorities); - } - public static ValidationResponse accepted(Set roles, Set authorities) { - return new ValidationResponse(true, null, roles, authorities); - } - public static ValidationResponse rejected(FailureReason reason) { - return new ValidationResponse(false, reason, Set.of(), Set.of()); - } - } - - public enum FailureReason { - SECURITY_PROJECTION_MISSING, - SECURITY_PROJECTION_NOT_READY, - USER_DISABLED, - TENANT_DISABLED, - PERMISSION_VERSION_STALE - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java deleted file mode 100644 index 5e888487b..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ -@Component -@Slf4j -public class LastlegSecurityProjectionRedisMirror { - private final StringRedisTemplate redis; - private final ObjectMapper objectMapper; - @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") - private String keyPrefix; - - public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { - this.redis = redis; - this.objectMapper = objectMapper; - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) { - var value = event.projection(); - try { - redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), - objectMapper.writeValueAsString(value)); - } catch (Exception exception) { - log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}", - value.aggregateType(), value.aggregateId()); - } - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java deleted file mode 100644 index 298d506c6..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; - -@Service -public class LastlegSecurityProjectionService { - private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, - EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); - private final JdbcTemplate jdbc; - private final ObjectMapper objectMapper; - private final ApplicationEventPublisher events; - - public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, - ApplicationEventPublisher events) { - this.jdbc = jdbc; - this.objectMapper = objectMapper; - this.events = events; - } - - @Transactional - public ConsumeResult consume(EventEnvelope envelope) { - envelope.requireValid(); - requireSecurityEnvelope(envelope); - String payloadHash = validatedStorageHash(envelope); - String payloadJson = envelope.payload().toString(); - try { - jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " - + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), - payloadHash, payloadJson); - } catch (DuplicateKeyException duplicate) { - InboxRow existing = jdbc.queryForObject( - "SELECT event_type,payload_hash,status FROM ll_security_event_inbox " - + "WHERE event_id=? FOR UPDATE", - (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), - envelope.eventId()); - if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) - || existing.payloadHash() == null - || !existing.payloadHash().equalsIgnoreCase(payloadHash)) { - throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); - } - if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); - } - - ProjectionInput input = input(envelope, payloadHash); - ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); - SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide( - current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), - input.aggregateVersion(), input.payloadHash()); - String result = decision.name(); - if (decision == SecurityProjectionMonotonicity.Decision.APPLY) { - upsert(input); - result = "APPLIED"; - events.publishEvent(new ProjectionChanged(input)); - } - jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " - + "WHERE event_id=?", result, envelope.eventId()); - return new ConsumeResult(result, input); - } - - private void requireSecurityEnvelope(EventEnvelope envelope) { - if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION - || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { - throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件"); - } - } - - static String validatedStorageHash(EventEnvelope envelope) { - if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), - envelope.payloadHash())) { - throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致"); - } - // 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。 - return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT); - } - - private ProjectionInput input(EventEnvelope envelope, String payloadHash) { - try { - return switch (envelope.eventType()) { - case EventTypes.IAM_USER_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), - IamSecurityEventsV1.UserStatusChanged.class); - requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), value.permissionVersion(), - envelope.aggregateVersion()); - requireStatus(value.status()); - yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), - value.status(), value.permissionVersion(), envelope.aggregateVersion(), - payloadHash, envelope.eventId(), value.changedAt(), Set.of(), Set.of()); - } - case EventTypes.IAM_TENANT_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), - IamSecurityEventsV1.TenantStatusChanged.class); - requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), - envelope.aggregateVersion()); - requireStatus(value.status()); - yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), - value.status(), null, envelope.aggregateVersion(), payloadHash, - envelope.eventId(), value.changedAt(), Set.of(), Set.of()); - } - case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), - IamSecurityEventsV1.PermissionVersionChanged.class); - requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); - requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), - envelope.aggregateVersion()); - requireAuthorizationSnapshot(value.roles(), value.authorities()); - yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), - null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, - envelope.eventId(), value.changedAt(), value.roles(), value.authorities()); - } - default -> throw new IllegalArgumentException("unsupported type"); - }; - } catch (BizException exception) { - throw exception; - } catch (Exception exception) { - throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效"); - } - } - - private static void requireIdentity(EventEnvelope envelope, String aggregateType, - long aggregateId, long tenantId) { - if (!aggregateType.equals(envelope.aggregateType()) - || !String.valueOf(aggregateId).equals(envelope.aggregateId()) - || !String.valueOf(tenantId).equals(envelope.tenantId())) { - throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致"); - } - } - - private static void requirePayload(String requiredText, Instant changedAt, - long payloadVersion, long aggregateVersion) { - if (requiredText == null || requiredText.isBlank() || changedAt == null - || payloadVersion != aggregateVersion) { - throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); - } - } - - private static void requireStatus(String status) { - if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { - throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED"); - } - } - - private static void requireAuthorizationSnapshot(Set roles, Set authorities) { - if (roles == null || authorities == null - || roles.stream().anyMatch(value -> value == null || value.isBlank()) - || authorities.stream().anyMatch(value -> value == null || value.isBlank())) { - throw new BizException(ResultCode.BAD_REQUEST, "IAM 海外授权快照无效"); - } - } - - private ProjectionRow findForUpdate(String type, String id) { - List rows = jdbc.query( - "SELECT aggregate_version,payload_hash FROM ll_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", - (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); - return rows.isEmpty() ? null : rows.get(0); - } - - private void upsert(ProjectionInput value) { - final String rolesJson; - final String authoritiesJson; - try { - rolesJson = objectMapper.writeValueAsString(value.roles()); - authoritiesJson = objectMapper.writeValueAsString(value.authorities()); - } catch (Exception exception) { - throw new IllegalStateException("海外授权快照序列化失败", exception); - } - jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," - + "permission_version,roles_json,authorities_json,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " - + "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," - + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," - + "roles_json=VALUES(roles_json),authorities_json=VALUES(authorities_json)," - + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," - + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", - value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), - value.permissionVersion(), rolesJson, authoritiesJson, value.aggregateVersion(), - value.payloadHash(), value.eventId(), value.changedAt()); - } - - private record InboxRow(String eventType, String payloadHash, String status) { } - private record ProjectionRow(long aggregateVersion, String payloadHash) { } - public record ConsumeResult(String result, ProjectionInput projection) { } - public record ProjectionChanged(ProjectionInput projection) { } - public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status, - Long permissionVersion, long aggregateVersion, String payloadHash, - String eventId, Instant changedAt, Set roles, - Set authorities) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java deleted file mode 100644 index 1d8224b3f..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Set; - -@Service -public class LastlegSecurityValidationService { - private final JdbcTemplate jdbc; - private final ObjectMapper objectMapper; - - public LastlegSecurityValidationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { - this.jdbc = jdbc; - this.objectMapper = objectMapper; - } - - public ValidationResponse validate(ValidationRequest request) { - String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId()); - if (userStatus == null) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - if (!"ACTIVE".equals(userStatus)) { - return ValidationResponse.rejected(FailureReason.USER_DISABLED); - } - if (request.tenantId() > 0) { - String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId()); - if (tenantStatus == null) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - if (!"ACTIVE".equals(tenantStatus)) { - return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); - } - } - List projectedVersions = jdbc.query( - "SELECT permission_version FROM ll_security_projection " - + "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?", - (rs, row) -> rs.getLong(1), - String.valueOf(request.userId()), request.tenantId()); - if (projectedVersions.isEmpty()) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - long projectedVersion = projectedVersions.get(0); - if (request.permissionVersion() < projectedVersion) { - return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); - } - if (request.permissionVersion() > projectedVersion) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY); - } - List snapshots = jdbc.query( - "SELECT CONCAT(roles_json, CHAR(10), authorities_json) FROM ll_security_projection " - + "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?", - (rs, row) -> rs.getString(1), String.valueOf(request.userId()), request.tenantId()); - if (snapshots.isEmpty() || snapshots.get(0) == null || !snapshots.get(0).contains("\n")) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - try { - String[] values = snapshots.get(0).split("\n", 2); - return ValidationResponse.accepted(readSet(values[0]), readSet(values[1])); - } catch (Exception exception) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - } - - private String status(String type, String id, long tenantId) { - List values = jdbc.query("SELECT security_status FROM ll_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?", - (rs, row) -> rs.getString(1), type, id, tenantId); - return values.isEmpty() ? null : values.get(0); - } - - private Set readSet(String json) throws Exception { - if (json == null || json.isBlank()) return Set.of(); - return objectMapper.readValue(json, new TypeReference>() { }); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java deleted file mode 100644 index 4085bc4de..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; - -import java.util.Objects; - -final class SecurityProjectionMonotonicity { - private SecurityProjectionMonotonicity() { } - - static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { - if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; - if (incomingVersion < currentVersion) return Decision.STALE; - if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION; - throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突"); - } - - enum Decision { APPLY, STALE, DUPLICATE_VERSION } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java index 52bf9194a..23ec86465 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -41,8 +40,7 @@ public class InboundOrderDetainedOutboxService { String causationId = EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null; EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, - RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER", + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(), eventKey, causationId, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 12f79d4c1..1b5471092 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -56,8 +56,6 @@ public class LoadingCompletedConsumer { if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3)) throw new EventProtocolException("不支持的尾程交接事件"); - if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) - throw new EventProtocolException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) throw new EventProtocolException("尾程交接事件聚合类型无效"); if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), @@ -283,7 +281,7 @@ public class LoadingCompletedConsumer { FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource, String containerNo, String sourceContainerBookingId, OwnerSnapshot owner) { String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); - LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); + LastlegOrder existing=orderMapper.findSource(event.tenantId(),"FIRSTLEG",source.sourceInboundId()); if(existing!=null) { if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId()); return; @@ -302,7 +300,7 @@ public class LoadingCompletedConsumer { } order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); order.setDestinationType(destinationType); - order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); + order.setInboundCode(source.inboundCode()); order.setSourceRegion("FIRSTLEG"); order.setLxCode("ONE_CLICK_DROPSHIP".equals(orderType) ? source.lxCode() : null); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); @@ -331,7 +329,7 @@ public class LoadingCompletedConsumer { try { orderMapper.insert(order); } catch (DuplicateKeyException exception) { - LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(), + LastlegOrder raced = orderMapper.findSource(event.tenantId(), "FIRSTLEG", source.sourceInboundId()); if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return; throw exception; @@ -399,7 +397,7 @@ public class LoadingCompletedConsumer { String orderHash) { LastlegInboundOrder snapshot = new LastlegInboundOrder(); snapshot.setId(order.getId()); - snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion(event.sourceRegion().name()); + snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion("FIRSTLEG"); snapshot.setSourceInboundId(source.sourceInboundId()); snapshot.setRootInboundId(source.rootInboundId()); snapshot.setSourceOrderId(source.sourceOrderId()); snapshot.setSourceOrderNo(source.sourceOrderNo()); snapshot.setRootOrderId(source.rootOrderId()); snapshot.setInboundCode(source.inboundCode()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index 0e7b69132..1801f6c13 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -8,7 +8,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventInbox; import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; @@ -104,8 +103,7 @@ public class ContainerShippingNodeConsumer { throw new EventProtocolException("payloadHash 与船节点事件负载不一致"); } if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) - || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN - || envelope.targetRegion() != RegionCode.OVERSEAS + || envelope.eventVersion() != 1 || !"CONTAINER_BOOKING".equals(envelope.aggregateType()) || envelope.handoverVersion() == null || envelope.handoverVersion() != envelope.aggregateVersion() + 1) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java index ca8858b46..4d6f51e58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -6,7 +6,6 @@ import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandCompletedV1; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -54,9 +53,8 @@ public class WalletCommandCompletedConsumer { try { EventEnvelopeValidator.requireValid(envelope); } catch (RuntimeException exception) { throw bad("资金结果事件信封无效"); } if (!EventTypes.WALLET_COMMAND_COMPLETED.equals(envelope.eventType()) - || envelope.eventVersion() != WalletCommandCompletedV1.VERSION - || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { - throw bad("资金结果事件类型、版本或区域无效"); + || envelope.eventVersion() != WalletCommandCompletedV1.VERSION) { + throw bad("资金结果事件类型或版本无效"); } if (!EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(envelope.payload())).equals(envelope.payloadHash())) { throw bad("资金结果事件载荷哈希无效"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java index 5225583d9..bf5320cdb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java @@ -6,7 +6,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandRequestedV1; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -36,7 +35,7 @@ public class WalletCommandOutboxService { businessNo, amount, remark, idempotencyKey, Instant.now()); JsonNode payload = objectMapper.valueToTree(value); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED, - WalletCommandRequestedV1.VERSION, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + WalletCommandRequestedV1.VERSION, Instant.now(), String.valueOf(customerId), businessType, businessNo, aggregateVersion, null, EventIdentifiers.newUuid(), eventKey, null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 6164da56a..21524c7a4 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -8,35 +8,35 @@ spring: max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB} sql: init: - mode: always + mode: never encoding: UTF-8 schema-locations: classpath:db/lastleg.sql data: redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} application: name: lastleg-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${LASTLEG_DB_USERNAME:root} - password: ${LASTLEG_DB_PASSWORD:123456} + url: ${LASTLEG_DB_URL} + username: ${LASTLEG_DB_USERNAME} + password: ${LASTLEG_DB_PASSWORD} management.endpoints.web.exposure.include: health,info,prometheus lastleg: @@ -76,21 +76,16 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} + url: ${FILE_SERVICE_URL:http://file:9206} domestic-file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} + url: ${FILE_SERVICE_URL:http://file:9206} firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a558dba68..d60edd834 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2327,181 +2327,6 @@ CREATE TABLE IF NOT EXISTS ll_customer_visit ( KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) ) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; -CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( - event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', - event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', - payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', - payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', - status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', - result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', - received_at DATETIME(3) NOT NULL COMMENT '接收时间', - processed_at DATETIME(3) NULL COMMENT '处理完成时间', - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; - -CREATE TABLE IF NOT EXISTS ll_security_projection ( - aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', - tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', - security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', - permission_version BIGINT NULL COMMENT '当前单调权限版本', - roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', - authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照', - aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', - payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', - last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', - changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', - PRIMARY KEY (aggregate_type,aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id,permission_version) -) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; - --- 收敛尚未具备授权快照字段的开发/测试库,CREATE TABLE IF NOT EXISTS 不会补充已存在表的字段。 -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', - 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1', - 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; -UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; -ALTER TABLE ll_security_projection - MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', - MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; - --- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 -SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); -SET @platform_admin_overseas_authorities = JSON_ARRAY( - 'platform:overseas-warehouse:warehouse-transfer:approve', - 'platform:finance:bill:adjust', - 'platform:finance:bill:update', - 'platform:finance:bill:void', - 'platform:finance:cost:create', - 'platform:finance:cost:view', - 'platform:finance:customer-account:adjust', - 'platform:finance:customer-account:export', - 'platform:finance:customer-account:list', - 'platform:finance:customer-arap:export', - 'platform:finance:customer-arap:list', - 'platform:finance:customer-statement:export', - 'platform:finance:customer-statement:list', - 'platform:finance:customer-terms:update', - 'platform:finance:customer-terms:view', - 'platform:finance:fee-source:edit', - 'platform:finance:fee-source:review', - 'platform:finance:payment:create', - 'platform:finance:receipt:create', - 'platform:finance:receipt:view', - 'platform:finance:receivable-statistics:export', - 'platform:finance:receivable-statistics:list', - 'platform:finance:recharge:review', - 'platform:finance:reconciliation:adjust', - 'platform:finance:reconciliation:create', - 'platform:finance:reconciliation:issue', - 'platform:finance:reconciliation:review', - 'platform:finance:reconciliation:void', - 'platform:finance:settlement-ap:create', - 'platform:finance:settlement-ap:export', - 'platform:finance:settlement-ap:list', - 'platform:finance:settlement-ar:create', - 'platform:finance:settlement-ar:export', - 'platform:finance:settlement-ar:list', - 'platform:finance:settlement:void', - 'platform:overseas-business:looseboard:create', - 'platform:overseas-business:looseboard:edit', - 'platform:overseas-business:looseboard:list', - 'platform:overseas-business:looseboard:status', - 'platform:overseas-business:purchase:edit', - 'platform:overseas-business:purchase:list', - 'platform:overseas-business:purchase:review', - 'platform:overseas-business:truck:create', - 'platform:overseas-business:truck:edit', - 'platform:overseas-business:truck:list', - 'platform:overseas-business:truck:status', - 'platform:overseas-master-data:exchange-rate:create', - 'platform:overseas-master-data:exchange-rate:edit', - 'platform:overseas-master-data:exchange-rate:export', - 'platform:overseas-master-data:exchange-rate:list', - 'platform:overseas-master-data:exchange-rate:status', - 'platform:overseas-master-data:quote:create', - 'platform:overseas-master-data:quote:edit', - 'platform:overseas-master-data:quote:export', - 'platform:overseas-master-data:quote:list', - 'platform:overseas-master-data:quote:status', - 'platform:overseas-master-data:vehicle:create', - 'platform:overseas-master-data:vehicle:edit', - 'platform:overseas-master-data:vehicle:export', - 'platform:overseas-master-data:vehicle:list', - 'platform:overseas-master-data:vehicle:status', - 'platform:overseas-warehouse:customer-visit:edit', - 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:delivery:documents', - 'platform:overseas-warehouse:delivery:list', - 'platform:overseas-warehouse:delivery:return', - 'platform:overseas-warehouse:delivery:status', - 'platform:overseas-warehouse:dispatch:cancel', - 'platform:overseas-warehouse:dispatch:edit', - 'platform:overseas-warehouse:dispatch:list', - 'platform:overseas-warehouse:dispatch:transfer-delivery', - 'platform:overseas-warehouse:exception-order:create', - 'platform:overseas-warehouse:exception-order:list', - 'platform:overseas-warehouse:exception-order:process', - 'platform:overseas-warehouse:inbound-order:create', - 'platform:overseas-warehouse:inbound-order:edit', - 'platform:overseas-warehouse:inbound-order:fba-appointment', - 'platform:overseas-warehouse:inbound-order:list', - 'platform:overseas-warehouse:inbound-order:smart-dispatch', - 'platform:overseas-warehouse:inventory:adjust', - 'platform:overseas-warehouse:inventory:edit', - 'platform:overseas-warehouse:inventory:export', - 'platform:overseas-warehouse:inventory:list', - 'platform:overseas-warehouse:last-mile-order:edit', - 'platform:overseas-warehouse:last-mile-order:export', - 'platform:overseas-warehouse:last-mile-order:list', - 'platform:overseas-warehouse:location:create', - 'platform:overseas-warehouse:location:delete', - 'platform:overseas-warehouse:location:edit', - 'platform:overseas-warehouse:location:list', - 'platform:overseas-warehouse:outbound:cancel', - 'platform:overseas-warehouse:outbound:create', - 'platform:overseas-warehouse:outbound:list', - 'platform:overseas-warehouse:pallet:create', - 'platform:overseas-warehouse:pallet:delete', - 'platform:overseas-warehouse:pallet:edit', - 'platform:overseas-warehouse:pallet:list', - 'platform:overseas-warehouse:relabel-order:create', - 'platform:overseas-warehouse:relabel-order:list', - 'platform:overseas-warehouse:relabel-order:status', - 'platform:overseas-warehouse:return-cartons:list', - 'platform:overseas-warehouse:return-inventory:list', - 'platform:overseas-warehouse:warehouse:create', - 'platform:overseas-warehouse:warehouse:edit', - 'platform:overseas-warehouse:warehouse:list', - 'platform:overseas-warehouse:warehouse:status' -); -INSERT IGNORE INTO ll_security_projection ( - aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, - payload_hash,last_event_id,changed_at,updated_at -) VALUES - ('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256), - 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), - ('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0, - SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), - 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); - -UPDATE ll_security_projection -SET roles_json=@platform_admin_overseas_roles, - authorities_json=@platform_admin_overseas_authorities, - payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), - updated_at=CURRENT_TIMESTAMP(3) -WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 - AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'; - - CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID', @@ -2692,3 +2517,38 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason')); PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt; + +CREATE TABLE IF NOT EXISTS event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java index 154faaaf0..060d7b1ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -70,8 +70,7 @@ class DirectEventClientTest { private EventEnvelope event(String type) { var payload = new ObjectMapper().createObjectNode().put("businessNo", "test"); - return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), - RegionCode.OVERSEAS, RegionCode.CN, "1", "BUSINESS", "1", 1L, null, + return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), "1", "BUSINESS", "1", 1L, null, "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..5734977e9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java index 3291ef092..af63f701c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java @@ -8,6 +8,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.UUID; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -16,13 +21,18 @@ class OverseasFeeOutboxRetryTest { var repository = mock(OverseasFeeOutboxRepository.class); var client = mock(DirectEventClient.class); var relay = new OverseasFeeOutboxRelay(); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + ReflectionTestUtils.setField(relay, "journal", new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource))); + when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1); ReflectionTestUtils.setField(relay, "repository", repository); ReflectionTestUtils.setField(relay, "client", client); ReflectionTestUtils.setField(relay, "objectMapper", new ObjectMapper()); var row = new LastlegEventOutbox(); - row.setId(1L); row.setRetryCount(11); row.setPayloadJson("invalid-json"); + row.setId(1L); row.setEventType("BusinessFeeSourceChanged"); row.setRetryCount(11); row.setPayloadJson("invalid-json"); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(1L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(1L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(1L), anyString())).thenReturn(row); relay.relay(); row.setRetryCount(12); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index d542c183b..28ea51299 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; @@ -23,6 +22,11 @@ import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -35,11 +39,21 @@ class InboundUnloadCompletedOutboxRelayTest { private ObjectMapper objectMapper; private InboundUnloadCompletedOutboxRelay relay; + private EventDeliveryJournal journal; + @BeforeEach void setUp() { objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); relay = new InboundUnloadCompletedOutboxRelay(); ReflectionTestUtils.setField(relay, "repository", repository); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + ReflectionTestUtils.setField(relay, "journal", journal); + lenient().when(repository.published(anyLong(), anyString(), any())).thenReturn(1); + lenient().when(repository.retry(anyLong(), anyString(), any(), anyString())).thenReturn(1); + lenient().when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1); ReflectionTestUtils.setField(relay, "client", client); ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); @@ -52,7 +66,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED))); @@ -68,7 +82,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenThrow(new IllegalStateException("consumer unavailable")); @@ -84,7 +98,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST, @@ -102,7 +116,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.success(new DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), DeliveryAck.Outcome.APPLIED))) @@ -118,7 +132,7 @@ class InboundUnloadCompletedOutboxRelayTest { LastlegEventOutbox row = row(event()); row.setRetryCount(11); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); relay.relay(); @@ -132,7 +146,7 @@ class InboundUnloadCompletedOutboxRelayTest { LastlegEventOutbox row = row(event()); row.setRetryCount(12); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); relay.relay(); verifyNoInteractions(client); @@ -140,6 +154,19 @@ class InboundUnloadCompletedOutboxRelayTest { verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); } + @Test + void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception { + var row = row(event()); + row.setStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1); + journal.begin("old", "lastleg", row.getEventType(), "9001", null, "consumer", LocalDateTime.now().minusMinutes(2), () -> true); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.expire(eq(9001L), eq("old"), any(), eq(2), any(), anyString())).thenReturn(1); + relay.relay(); + verify(repository).expire(eq(9001L), eq("old"), any(), eq(2), argThat(next -> next.isAfter(LocalDateTime.now())), anyString()); + verify(repository, never()).claim(anyLong(), anyString(), any()); + verifyNoInteractions(client); + } + private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); @@ -153,7 +180,7 @@ class InboundUnloadCompletedOutboxRelayTest { Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); return new EventEnvelope("00000000-0000-4000-8000-000000000009", EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"), - RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L, + "1", "CONTAINER_BOOKING", "1001", 7L, 1L, "trace-12345678", "correlation-12345678", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java index 1ad8c3c5e..973ae5eaf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -116,7 +116,6 @@ class InboundUnloadCompletedOutboxServiceTest { Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null); EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000", EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"), - com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN, customerId, "CONTAINER_BOOKING", bookingId, 7, 1L, "123e4567-e89b-12d3-a456-426614174001", EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index 5c9b6f049..3cddb32a8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -2,55 +2,93 @@ package com.baiyue.lastleg.repository; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.common.event.EventTypes; -import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; -import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.mybatis.spring.SqlSessionTemplate; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; class OutboxLeaseDatabaseTest { - @Test void recoveredLeasesCountFailuresAndOldOwnersCannotChangeNewState() throws Exception { - var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() - + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + @Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var jdbc = new JdbcTemplate(dataSource); - jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_type VARCHAR(64),event_version INT,event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); var configuration = new MybatisConfiguration(); configuration.addMapper(LastlegEventOutboxMapper.class); var factory = new MybatisSqlSessionFactoryBean(); - factory.setDataSource(dataSource); - factory.setConfiguration(configuration); - try (var session = factory.getObject().openSession(true)) { - var mapper = session.getMapper(LastlegEventOutboxMapper.class); - var unload = new UnloadCompletedOutboxRepository(); - ReflectionTestUtils.setField(unload, "mapper", mapper); - var fee = new OverseasFeeOutboxRepository(mapper); - LocalDateTime now = LocalDateTime.now(); - for (long id : new long[]{1, 2}) { - jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(?,?,'PENDING',0,?,?)", - id, id == 1 ? EventTypes.OVERSEAS_CONTAINER_UNLOADED : EventTypes.BUSINESS_FEE_SOURCE_CHANGED, - now.minusSeconds(1), now.minusMinutes(2)); - boolean first = id == 1 ? unload.claim(id, "old", now, now.minusSeconds(60)) : fee.claim(id, "old", now, now.minusSeconds(60)); - assertThat(first).isTrue(); - assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isZero(); - jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=?", now.minusSeconds(61), id); - boolean second = id == 1 ? unload.claim(id, "new", now, now.minusSeconds(60)) : fee.claim(id, "new", now, now.minusSeconds(60)); - assertThat(second).isTrue(); - assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isEqualTo(1); - if (id == 1) unload.dead(id, "old", 12, "late failure"); else fee.dead(id, "old", 12, "late failure"); - assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("PROCESSING"); - if (id == 1) unload.dead(id, "new", 12, "exhausted"); else fee.dead(id, "new", 12, "exhausted"); - assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("DEAD"); - boolean third = id == 1 ? unload.claim(id, "third", now, now.minusSeconds(60)) : fee.claim(id, "third", now, now.minusSeconds(60)); - assertThat(third).isFalse(); - } - jdbc.update("UPDATE ll_event_outbox SET status='PENDING',next_retry_at=? WHERE id=2", now.plusMinutes(5)); - assertThat(fee.claim(2L, "early", now, now.minusSeconds(60))).isFalse(); - } + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); + var repository = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); + assertThat(repository.claim(1L, "new", now)).isFalse(); + var next = now.plusSeconds(10); + assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next, + () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1); + assertThat(repository.claim(1L, "early", now)).isFalse(); + assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", next, + () -> repository.claim(1L, "new", next))).isTrue(); + assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, + () -> repository.dead(1L, "old", 12, "late"))).isFalse(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PROCESSING"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isTrue(); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PUBLISHED"); + } + @Test void overseasFeeOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(LastlegEventOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); + assertThat(repository.claim(1L, "new", now)).isFalse(); + var next = now.plusSeconds(10); + assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next, + () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1); + assertThat(repository.claim(1L, "early", now)).isFalse(); + assertThat(journal.begin("new", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", next, + () -> repository.claim(1L, "new", next))).isTrue(); + assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, + () -> repository.dead(1L, "old", 12, "late"))).isFalse(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PROCESSING"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isTrue(); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PUBLISHED"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java index 24ad3ccf8..cf0f50759 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -27,8 +27,8 @@ class UnloadCompletedOutboxRepositoryTest { ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); - assertThat(repository.claim(1L, "attempt", now, now.minusMinutes(1))).isTrue(); - assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status", "locked_at"); + assertThat(repository.claim(1L, "attempt", now)).isTrue(); + assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status").doesNotContain("locked_at"); } @Test @@ -43,9 +43,9 @@ class UnloadCompletedOutboxRepositoryTest { when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); - assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + assertThat(repository.claim(1L, "worker", now)).isTrue(); assertThat(wrapper.getValue().getSqlSegment()) - .contains("next_retry_at", "<=", "status", "locked_at"); + .contains("next_retry_at", "<=", "status").doesNotContain("locked_at"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java deleted file mode 100644 index 0fca27fe0..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.baiyue.lastleg.security; - -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class LastlegSecurityContractTest { - @Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception { - String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql"); - String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql"); - String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql"); - String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java"); - String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java"); - String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java"); - String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java"); - assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox", - "PRIMARY KEY (event_id)", "payload_hash CHAR(64)", - "CREATE TABLE IF NOT EXISTS ll_security_projection", - "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", - "permission_version BIGINT", "roles_json JSON", "authorities_json JSON"); - assertThat(baseline).contains("table_name='ll_security_projection' AND column_name='roles_json'", - "table_name='ll_security_projection' AND column_name='authorities_json'", - "UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL", - "UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL"); - assertThat(baseline).contains("SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN')", - "SET @platform_admin_overseas_authorities = JSON_ARRAY(", - "('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0", - "('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0", - "baseline-platform-admin-user-v0", - "baseline-platform-admin-authorization-v0", - "platform:overseas-warehouse:last-mile-order:list", - "platform:overseas-warehouse:inbound-order:list", - "platform:overseas-warehouse:dispatch:list", - "platform:overseas-business:purchase:list", - "platform:overseas-master-data:quote:list", - "platform:finance:fee-source:review", - "AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'") - .doesNotContain("('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0", - "baseline-merchant-admin"); - assertThat(migration).contains( - "CREATE TABLE ll_security_event_inbox (", - "CREATE TABLE ll_security_projection (", - "VALUES ('AUTHORIZATION','1000001',0,NULL,0,", - "VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0", - "PLATFORM_ADMIN", - "baseline-platform-admin-user-v0", - "baseline-platform-admin-authorization-v0", - "platform:overseas-warehouse:last-mile-order:list", - "platform:overseas-warehouse:inbound-order:list", - "platform:overseas-warehouse:dispatch:list", - "platform:overseas-business:purchase:list", - "platform:overseas-master-data:quote:list", - "platform:finance:fee-source:review") - .doesNotContain("SET @platform_admin_overseas_roles", "UPDATE ll_security_projection", - "baseline-merchant-admin"); - assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", - "default_app_code"); - assertThat(migration).contains("USE baiyue_lastleg;") - .doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security", - "USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox", - "CREATE TABLE IF NOT EXISTS overseas_security_projection"); - assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY"); - assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash") - .doesNotContain("LoadingCompletedConsumer"); - assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); - assertThat(validation).contains("SELECT permission_version", "authorities_json", "tenant_id=?", - "SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED", - "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); - assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion") - .doesNotContain("Long tokenVersion"); - } - - @Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception { - String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); - assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED", - "FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型") - .doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", - "IAM_PERMISSION_VERSION_CHANGED"); - } - - @Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception { - String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java"); - String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java"); - String application = read("lastleg-service/src/main/resources/application.yml"); - assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")", - "@PostMapping(\"/events\")", "@PostMapping(\"/validate\")", - "com.baiyue.lastleg.security.InternalServiceTokenGuard") - .doesNotContain("/internal/overseas-security/v1"); - assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"); - assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}") - .doesNotContain("phase1-local-internal-token"); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path)) path = Path.of("..").resolve(relative); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java deleted file mode 100644 index 27a8de243..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.baiyue.lastleg.security; - -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.Test; - -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -class SecurityProjectionArchitectureTest { - private static final String LASTLEG = "com.baiyue.lastleg.."; - private static final String CONTROLLER = "com.baiyue.lastleg.security.controller.."; - private static final String DTO = "com.baiyue.lastleg.security.dto.."; - private static final String SERVICE = "com.baiyue.lastleg.security.service.."; - - private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) - .importPackages("com.baiyue.lastleg"); - - @Test - void projectionInternalsDoNotDependOnLastlegBusinessSlices() { - classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE) - .should().onlyDependOnClassesThat( - resideOutsideOfPackage(LASTLEG) - .or(resideInAPackage(CONTROLLER)) - .or(resideInAPackage(DTO)) - .or(resideInAPackage(SERVICE)) - .or(resideInAPackage("com.baiyue.lastleg.security"))) - .check(PRODUCTION_CLASSES); - } - - @Test - void businessSlicesDoNotDependOnProjectionInternals() { - noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE) - .should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE) - .check(PRODUCTION_CLASSES); - } - - @Test - void dtoAndServicesStayIndependentFromInternalWebAdapter() { - noClasses().that().resideInAnyPackage(DTO, SERVICE) - .should().dependOnClassesThat().resideInAPackage(CONTROLLER) - .check(PRODUCTION_CLASSES); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java deleted file mode 100644 index 42f93f8df..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.exception.BizException; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class LastlegSecurityProjectionTransportTest { - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - - @Test - void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() { - EventEnvelope envelope = envelope(); - - String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope); - - assertThat(stored).hasSize(64).doesNotStartWith("sha256:"); - assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored); - } - - @Test - void rejectsTamperedPayloadBeforeInboxMutation() { - EventEnvelope source = envelope(); - EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), - source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), - source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), - source.traceId(), source.correlationId(), source.causationId(), source.payload(), - "sha256:" + "0".repeat(64)); - - assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered)) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash"); - } - - private EventEnvelope envelope() { - var value = new IamSecurityEventsV1.PermissionVersionChanged( - 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"), - java.util.Set.of("PLATFORM_ADMIN"), - java.util.Set.of("platform:overseas-warehouse:last-mile-order:list")); - var payload = objectMapper.valueToTree(value); - return new EventEnvelope("00000000-0000-4000-8000-000000000091", - EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, - Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0", - "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", - null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java deleted file mode 100644 index 3bf58ccf4..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; - -class LastlegSecurityValidationServiceTest { - private final JdbcTemplate jdbc = mock(JdbcTemplate.class); - private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc, new ObjectMapper()); - - @Test void disabledUserFailsClosed() { - doReturn(List.of("DISABLED")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) - .isEqualTo(FailureReason.USER_DISABLED); - } - - @Test void disabledCustomerFailsClosed() { - doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) - .isEqualTo(FailureReason.TENANT_DISABLED); - } - - @Test void missingUserProjectionFailsClosed() { - doReturn(List.of()).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); - } - - @Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() { - doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L), - List.of("[]\n[\"platform:overseas-warehouse:last-mile-order:list\"]")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue(); - } - - @Test void permissionVersionMustExactlyMatchReliableProjection() { - doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L), - List.of("ACTIVE"), List.of(9L), List.of("[]\n[]")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) - .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); - assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY); - assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue(); - } - - @Test void missingPermissionProjectionFailsClosed() { - doReturn(List.of("ACTIVE"), List.of()).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java deleted file mode 100644 index b385e2aea..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class SecurityProjectionMonotonicityTest { - @Test void appliesFirstAndNewerVersions() { - assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); - assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); - } - - @Test void ignoresOlderAndSameHashVersions() { - assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.STALE); - assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION); - } - - @Test void rejectsSameVersionWithDifferentHash() { - assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 16dd17461..c6c9c3a00 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -6,18 +6,18 @@ class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } - @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } + @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } - @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } + @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } - @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } + @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId")); @@ -25,8 +25,8 @@ class LoadingCompletedConsumerTest { String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item (")); assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } - @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void freezesReceivingWarehouseInInitialInTransitInventory(){ service.consume(event()); ArgumentCaptor captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); @@ -64,8 +64,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) .set("cartonMarks", json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class); @@ -83,8 +82,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2)); assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数")); @@ -97,8 +95,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) .set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); @@ -108,8 +105,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); @@ -124,7 +120,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); @@ -224,7 +220,7 @@ class LoadingCompletedConsumerTest { verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); } private EventEnvelope rehash(EventEnvelope original,JsonNode node) { - return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); } private EventEnvelope overseasDropshipEvent(boolean duplicateSku) { EventEnvelope original=event(); @@ -239,15 +235,15 @@ class LoadingCompletedConsumerTest { if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy()); node.putArray("orders").add(sourceOrder); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash); + return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash); } - private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } @Test void projectsFiveDomesticLoadingPhotosOncePerPhysicalContainer(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(source); var values=node.putArray("loadingEvidence"); int fileId=10; for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class); verify(loadingEvidence,times(5)).insert(captured.capture()); @@ -257,13 +253,13 @@ class LoadingCompletedConsumerTest { @Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){ EventEnvelope source=event(); JsonNode node=v2Payload(source); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("五类国内装柜照片")); verify(loadingEvidence,never()).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class)); } private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; } - private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } + private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 6c73a9d5b..a9ea0af18 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventInbox; import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; @@ -101,7 +100,7 @@ class ContainerShippingNodeConsumerTest { EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5, Instant.parse("2026-08-21T10:00:00Z")); EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), - source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.occurredAt(), source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), source.payload(), "sha256:" + "0".repeat(64)); @@ -126,7 +125,7 @@ class ContainerShippingNodeConsumerTest { JsonNode payloadNode = json.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, - actualAt, RegionCode.CN, RegionCode.OVERSEAS, "0", "CONTAINER_BOOKING", "CB-1", + actualAt, "0", "CONTAINER_BOOKING", "CB-1", version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null, payloadNode, hash); } diff --git a/order/src/main/resources/application-dev.yml b/order/src/main/resources/application-dev.yml deleted file mode 100644 index bef45e064..000000000 --- a/order/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${ORDER_DB_USERNAME:root} - password: ${ORDER_DB_PASSWORD:123456} diff --git a/order/src/main/resources/application-test.yml b/order/src/main/resources/application-test.yml deleted file mode 100644 index 8e0bc7e3b..000000000 --- a/order/src/main/resources/application-test.yml +++ /dev/null @@ -1,17 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} - -baiyue.order.inbound-code-mode: AUTO diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index a0883c3c1..7e7ae8b20 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -2,32 +2,31 @@ server: port: ${ORDER_PORT:9204} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: order-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 management.endpoints.web.exposure.include: health,info,prometheus baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} clients: iam: url: ${IAM_SERVICE_URL:} diff --git a/pom.xml b/pom.xml index 73064fb44..6755a57e4 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,6 @@ common-audit common-event-contract gateway - gateway-overseas auth iam order @@ -38,7 +37,6 @@ audit-service finance integration-service - event-bridge-service diff --git a/print/AGENTS.md b/print/AGENTS.md index 0e81b655c..3e9af5ab7 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -24,7 +24,7 @@ | PDF 参数配置 | `config/PrintPdfProperties.java` | | 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` | | 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` | -| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` | +| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/print-service.yml` | | 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` | ## 修改与验证 diff --git a/print/src/main/resources/application.yml b/print/src/main/resources/application.yml index e01f7b2b9..221b6d870 100644 --- a/print/src/main/resources/application.yml +++ b/print/src/main/resources/application.yml @@ -2,27 +2,26 @@ server: port: ${PRINT_PORT:9207} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: print-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:} print: pdf: diff --git a/scripts/README.md b/scripts/README.md index 7e44caf20..203657136 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,7 +16,6 @@ `db/test-return-relabel-checks.sql`:测试库退货转换标检查约束定向修复,显式目标 `baiyue_lastleg.ll_return_inventory_carton`。与 Lastleg 基线保持一致,允许正数量的 `RELABEL_PENDING`,保留可用/已出库正数量与已合箱零数量限制。一条原子 ALTER 同时替换两个既有 CHECK,不修改业务行、不删除数据、不关闭约束;重复执行得到相同约束,旧约束不存在则报错停止。凭据由外部测试数据库连接提供,执行前需测试库操作授权;末尾只读输出约束定义供回查。不是生产迁移入口,不能替代完整 HTTP 换标回归。 -`../deploy/scripts/deploy-blue-green-service.sh`:受控部署流程调用的单服务蓝绿发布与健康检查入口;只由部署/Jenkins 编排使用,不作为普通验收脚本直接运行。目标服务、环境、镜像和凭据均由外部参数提供,失败按部署脚本回滚规则处理。 `db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;修正未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单,并将已客户确认且换标完成、仍停在待换标/待分配的仓储转运单按目的仓恢复为待排车或待确认目的仓,同步尾程主单和换标履约状态;不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 @@ -159,9 +158,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 | | 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 | | 本地环境 | `start-gateway-overseas-local.ps1` | 使用本地 Maven 启动海外 Gateway,并将海外安全投影地址指向本机;会启动本地 Java 进程,不访问或修改共享环境 | -| 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 | -| Nacos 环境选择 | `use-nacos-environment.ps1` | 按显式环境与区域设置当前 PowerShell 进程的 Nacos、Spring Profile 和运行区域变量;开发/测试密码必须由参数或环境变量提供,不发布配置 | -| Nacos 配置发布 | `publish-nacos-config.ps1` | 将 `config/nacos/{local|cn|overseas}/*.yml` 发布到显式指定的 Nacos;非本地环境强制要求非 public namespace,会修改目标 Nacos 配置,执行前须取得对应环境授权 | +| Nacos 环境选择 | `use-nacos-environment.ps1` | 仅设置当前进程 TEST 配置;显式提供地址、实际Namespace ID与凭据,不联网、不写运行环境 | +| Nacos 配置发布 | `publish-nacos-config.ps1` | 仅发布 `config/nacos/test/*.yml`;必须指定实际Namespace ID,凭据从环境取得。`-DryRun`离线列清单;正式运行会写Nacos,需授权;失败停止,重跑完整匹配配置,不自动清理 | | 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 | | 迁移辅助 | `migrate-local-qualification-file.ps1` | 本地资质文件迁移辅助,会修改本地文件/数据;运行前核对脚本参数和备份策略 | | Jenkins 管理 | `provision-development-service-jobs.groovy` | Jenkins Script Console 批量准备开发服务 Job;默认 `applyChanges=false` 仅预览,改为 true 会创建 Job | @@ -356,3 +354,9 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 - `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。 - `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。 - `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。 + +部署离线合同:`overseas-deployment-contract.test.mjs` 现验证统一TEST拓扑,保留历史文件名便于调用;`deploy-built-service-rollback.test.mjs`以mock Docker验证失败与同契约回滚,无真实环境操作。唯一执行清单见 `../deploy/TEST单机部署执行清单.md`。 +# 事件人工重试运维入口 + +`event-outbox-operations.mjs`:单测试环境的内部运维脚本。只连接显式指定的生产服务地址,不通过公网 Gateway,不自动重试或跟随重定向。凭据只从进程环境 `EVENT_OPERATIONS_TOKEN` 读取,另须 `CHONGCHONG_ENV=test`;不要把真实凭据放入参数或仓库。`--service firstleg|lastleg|finance --base <服务源地址> --entry ` 只读查询状态和摘要,不返回业务负载。追加 `--retry --request-id <稳定UUID> --operator <操作人> --reason <原因> --hash ` 会写入目标测试服务的 Outbox 与审计表,执行前须已有该环境操作授权。仅 DEAD 原事件可重试,事件身份、负载及摘要保持不变;重复提交使用同一个请求编号,参数变化会被拒绝。三个服务须先部署 `event_delivery_retry_audit` 表,未设置至少32字节的运维凭据时入口关闭。脚本将脱敏结果输出到标准输出,不持久化凭据;离线验证为 `node --test scripts/event-outbox-operations.test.mjs`。 + diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 8de5b7f41..c7c5c6e2b 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -5,11 +5,11 @@ import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities const cn = readInitialization() const overseas = readInitialization('OVERSEAS') const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', - 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', 'baiyue_finance'] -const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse'] + 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_finance'] +const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] -test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 138]]) { +test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 135]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|138) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|135) AS initialized_tables;$/) } }) @@ -53,7 +53,7 @@ test('必要系统种子完整保留,但不写入示例资源与业务事实', file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) for (const name of ['wh_pallet', 'wh_location', 'wh_partner', 'wh_partner_type', 'wh_express_company', 'ord_order', 'fl_inbound_order', 'fin_fee_source']) assert.equal(seedRows(cn, name).length, 0, name) - assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], ['ll_security_projection']) + assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], []) assert.ok(seedRows(cn, 'wh_warehouse').every(r => r.tenant_id === '0')) assert.ok(seedRows(cn, 'wh_service_capability').every(r => r.service_type === 'RECEIVING' || r.capability_code === 'RECEIVING' || Object.values(r).includes('RECEIVING'))) }) @@ -98,19 +98,13 @@ test('近期头程与海外结构完整进入各自统一初始化入口', () => /shared_overflow tinyint\(1\) NOT NULL DEFAULT '?0'?/i) }) -test('海外固定身份投影完全匹配国内管理员的当前海外权限', () => { - const rows = seedRows(overseas, 'll_security_projection') - assert.equal(rows.length, 2) - const authorization = rows.find(r => r.projection_type === 'AUTHORIZATION' || r.subject_type === 'AUTHORIZATION' || Object.values(r).includes('AUTHORIZATION')) - assert.ok(authorization) - const authorities = JSON.parse(authorization.authorities_json) - const expected = [...roleAuthorities(cn, 'PLATFORM_ADMIN')].filter(code => - /^(platform:finance:|platform:overseas-business:|platform:overseas-master-data:|platform:overseas-warehouse:)/.test(code)) - assert.equal(authorities.length, 106) - assert.equal(new Set(authorities).size, authorities.length) - assert.deepEqual([...authorities].sort(), expected.sort()) - assert.deepEqual(JSON.parse(authorization.roles_json), ['PLATFORM_ADMIN']) - for (const row of rows) assert.ok(Object.values(row).includes('1000001')) +test('统一认证保留持久版本,不再初始化安全投影或桥接发件箱', () => { + for (const retired of ['iam_security_event_outbox', 'll_security_event_inbox', 'll_security_projection']) { + assert.doesNotMatch(cn + overseas, new RegExp('CREATE TABLE ' + retired + '\\b')) + } + assert.ok(table(cn, 'baiyue_iam', 'iam_security_subject_version')) + assert.ok(table(cn, 'baiyue_iam', 'iam_security_tenant_version')) + assert.equal(seedRows(overseas, 'll_security_projection').length, 0) }) test('跨区域放错位置的最终字段归正确区域且保留财务精度', () => { diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 44cbc3fa2..cb39c70ec 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -534,7 +534,6 @@ Assert-Matches $cabinetEvidenceService '(?s)mapper\.byOperation\(taskId, key\).{ $mapperModuleAgents = @( @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, - @{ Module = 'event-bridge-service'; Application = 'EventBridgeApplication.java' }, @{ Module = 'file'; Application = 'FileServiceApplication.java' }, @{ Module = 'firstleg'; Application = 'BaiyueFirstlegApplication.java' }, @{ Module = 'iam'; Application = 'BaiyueIamApplication.java' }, @@ -618,29 +617,24 @@ foreach ($route in @( Assert-Matches $clientOverseasRoutes $routePattern "Client 正式路由 $route" } -# 根索引中的海外浏览器 API 必须被海外 Gateway 显式覆盖。 -$overseasGatewayConfigPath = Join-Path $backendRoot 'gateway-overseas/src/main/resources/application-overseas.yml' -$overseasGatewayConfig = Read-RequiredText $overseasGatewayConfigPath -foreach ($gatewayPath in @( - '/api/merchant/v1/last-leg/**', - '/api/platform/v1/last-leg/**', - '/api/pda/v1/last-leg/**', - '/api/platform/v1/warehouses/**', - '/api/platform/v1/warehouse-admin/**' -)) { - Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas Gateway 路由' +# 四端统一 Gateway,保留服务与会话均来自 TEST 配置。 +$unifiedGateway = Read-RequiredText (Join-Path $backendRoot 'gateway/src/main/resources/application.yml') +foreach ($gatewayPath in @('/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', '/api/platform/v1/warehouse-admin/**', '/api/platform/v1/overseas-warehouse-resources/**')) { + Assert-Contains $unifiedGateway $gatewayPath '统一 Gateway 路由' } -Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' -Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' - -# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。 +Assert-Contains $unifiedGateway 'uri: lb://lastleg-service' 'Lastleg 服务路由' +Assert-Contains $unifiedGateway 'uri: lb://warehouse-overseas-service' '海外仓资源路由' $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') -$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') +$testCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.yml') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') -Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射' -Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址' -Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' -Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' +Assert-Contains $testCompose 'AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate' '统一权威会话校验' +Assert-Contains $testCompose 'REDIS_TOKEN_KEY_PREFIX' '统一会话前缀' +Assert-Contains $testCompose 'SPRING_PROFILES_ACTIVE: test' '唯一 TEST profile' +foreach ($retired in @('gateway-overseas:', 'event-bridge-service:')) { + Assert-NotContains $serviceMap $retired '部署映射' + Assert-NotContains $testCompose $retired 'TEST Compose' +} +Assert-Contains $backendAgents 'config/nacos/test/' '唯一 Nacos 配置入口' # 高变化语义必须在最近指引与服务契约中保持一致。 Assert-Contains $adminAgents '海外模块内部快速索引(海外仓全流程)' 'Admin 海外仓全流程范围' @@ -842,7 +836,6 @@ Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合 Assert-Contains $pdaAgents 'tests/pda-loading-api.test.mjs' '国内 PDA API 合同' # 国内柜费分摊与海外费用跨区入池必须能从根索引直达,并锁住归属、金额、版本与直接已付隔离。 $financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md') -$eventBridgeAgents = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/AGENTS.md') $commonEventAgents = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/AGENTS.md') $adminFinanceAgents = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/finance/AGENTS.md') $containerFeeOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/container-fees-v1.yaml') @@ -851,7 +844,8 @@ $containerFeeService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m $overseasFinanceOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml') $businessFeeEvent = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java') $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java') -$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java') +$directEventClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java') +$financeBusinessClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java') $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java') $financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java') $overseasFinancePhase0ContractPath = Join-Path $workspaceRoot 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md' @@ -889,11 +883,10 @@ Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceCha Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本' Assert-Contains $businessFeeEvent 'List paymentVoucherFileIds' '海外费用事件付款证据快照' Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希' -Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance' 'Event Bridge Finance 定向路由' -Assert-Contains $eventBridgeAgents '/internal/finance/v1/business-fee-source-events' 'Event Bridge Finance 内部入口' -Assert-Contains $overseasDispatcher 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED' 'Event Bridge 海外费用事件 Finance 路由集合' -Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event Bridge 海外资金指令 Finance 路由集合' -Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递' +Assert-Contains $directEventClient 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event))' '费用事件定向 Finance' +Assert-Contains $directEventClient 'EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event))' '资金指令定向 Finance' +Assert-Contains $financeBusinessClient '/internal/finance/v1/business-fee-source-events' 'Finance 费用内部入口' +Assert-Contains $financeBusinessClient '/internal/finance/v1/wallet-command-events' 'Finance 资金内部入口' Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界' Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由' @@ -927,6 +920,8 @@ Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw +if (@($pom.project.modules.module).Count -ne 20) { Add-Failure '当前聚合必须为20模块(7公共/依赖+13服务)' } +if ([regex]::Matches($serviceMap, '(?m)^ [a-z][a-z-]+:').Count -ne 13) { Add-Failure '当前发布映射必须为13服务' } foreach ($module in $pom.project.modules.module) { Assert-Contains $backendAgents "$module/" '后端 AGENTS.md' } @@ -1085,10 +1080,8 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java', - 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', - 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', - 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java', @@ -1097,15 +1090,13 @@ $criticalPaths = @( 'baiyue-firstleg/iam/src/main/resources/db/customer-dimension-initialization-task.sql', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', + 'baiyue-firstleg/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java', 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java', 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java', 'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java', - 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', - 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java', @@ -1148,8 +1139,6 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java', 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java', 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java', - 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', - 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', @@ -1181,6 +1170,13 @@ $criticalPaths = @( 'docs/财务管理/Firstleg-Finance跨服务契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ } +# phase4前端定向合同是本地测试入口;不代表真实页面或环境验收通过。 +foreach ($frontendTestPath in @('apps/admin-web/vitest.phase4-test.config.ts', 'apps/admin-web/scripts/phase4-test-frontend.acceptance.ts', 'apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs', 'apps/admin-web/scripts/phase4-test-frontend-contract.test.mjs')) { + Assert-Path $frontendTestPath +} +$adminPackage = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/package.json') +Assert-Contains $adminPackage '"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs"' 'Admin phase4 TEST入口' + foreach ($removedOverseasWarehouseMasterPath in @( 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java', @@ -1213,7 +1209,6 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), (Join-Path $backendRoot 'warehouse/AGENTS.md'), - (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') ) @@ -1252,5 +1247,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、统一 Token 与权威会话、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs index 09dd959af..03b984051 100644 --- a/scripts/audit-deployment-contract.test.mjs +++ b/scripts/audit-deployment-contract.test.mjs @@ -9,10 +9,10 @@ test('基础 Compose 显式配置审计服务地址,避免静默退化为日 const compose = await read('deploy/docker-compose.yml') assert.match(compose, - /BAIYUE_AUDIT_ENDPOINT: \$\{BAIYUE_AUDIT_ENDPOINT:-http:\/\/audit-service:9700\/internal\/audit\/events\}/) + /BAIYUE_AUDIT_ENDPOINT: http:\/\/audit-service:9700\/internal\/audit\/events/) assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m) assert.match(compose, /^ audit-service:\s*$/m) - assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/) + assert.match(compose, /container_name: firstleg-test-audit/) assert.match(compose, /networks: \[firstleg-network, infra-network\]/) }) diff --git a/scripts/blue-green-deployment-contract.test.mjs b/scripts/blue-green-deployment-contract.test.mjs deleted file mode 100644 index 6abd984a6..000000000 --- a/scripts/blue-green-deployment-contract.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import test from 'node:test' - -const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('all backend business services have blue-green runtime contracts', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - for (const service of [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'audit-service', 'gateway-overseas', 'lastleg-service', - 'warehouse-overseas-service', 'event-bridge-service' - ]) assert.match(script, new RegExp(`${service.replaceAll('-', '\\-')}:`)) - - assert.match(script, /target_slot="green"/) - assert.match(script, /target_port=\$\(\(BASE_PORT \+ 20000\)\)/) - assert.match(script, /target_port=\$\(\(BASE_PORT \+ 10000\)\)/) - assert.match(script, /compose run -d --no-deps --name "\$candidate"/) - assert.match(script, /wait_for_container "\$candidate"/) - assert.match(script, /BAIYUE_RUNTIME_SCHEDULING_ENABLED=false/) - assert.match(script, /NACOS_REGISTER_ENABLED=false/) - assert.match(script, /start_worker_candidate/) - assert.match(script, /worker_java_options="-Xms512m -Xmx2g/) - assert.match(script, /worker_java_options="-Xms256m -Xmx1g/) - assert.match(script, /connect_routing_aliases "\$candidate" "\$ROUTING_ALIAS" "\$NACOS_SERVICE"/) - assert.match(script, /file,file-service:file/) - assert.match(script, /docker stop --time "\$STOP_TIMEOUT" "\$active_container"/) - assert.match(script, /DRAIN_SECONDS="\$\{BLUE_GREEN_DRAIN_SECONDS:-30\}"/) - assert.match(script, /蓝绿切换后连接排空/) - assert.match(script, /sleep "\$DRAIN_SECONDS"/) - assert.match(script, /mv -f "\$STATE_FILE\.tmp" "\$STATE_FILE"/) -}) - -test('candidate failure keeps the active container and successful cutover stops it last', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - const start = script.lastIndexOf('compose run -d') - const healthy = script.indexOf('if ! wait_for_container "$candidate"') - const route = script.indexOf('connect_routing_aliases "$candidate"') - const gateway = script.indexOf('if ! switch_gateway_proxy') - const stop = script.indexOf('docker stop --time "$STOP_TIMEOUT" "$active_container"') - const state = script.indexOf('mv -f "$STATE_FILE.tmp" "$STATE_FILE"') - assert.ok(start >= 0 && start < healthy) - assert.ok(healthy < route && route < gateway && gateway < stop && stop < state) - assert.match(script.slice(healthy, route), /cleanup_candidate/) -}) - -test('gateway cutover is atomic and restores nginx config on validation failure', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - assert.match(script, /00-blue-green-gateway\.conf/) - assert.match(script, /00-blue-green-gateway-overseas\.conf/) - assert.doesNotMatch(script, /api-cn\.buggoglobal\.com\.conf|api-global\.buggoglobal\.com\.conf/) - assert.match(script, /尚未完成首次蓝绿迁移,拒绝切换 Gateway/) - assert.match(script, /frontend-\$frontend_service\.active/) - assert.match(script, /尚未使用稳定上游 \$required_alias,拒绝切换 Gateway/) - assert.match(script, /reload_frontend_proxies/) - assert.match(script, /cp -f "\$config" "\$backup"/) - assert.match(script, /nginx -t \|\| ! nginx -s reload/) - assert.match(script, /cp -f "\$backup" "\$config"/) -}) - -test('compose templates preserve rolling defaults and expose slot metadata', async () => { - const domestic = await read('deploy/docker-compose.yml') - const overseas = await read('deploy/docker-compose.overseas.yml') - for (const compose of [domestic, overseas]) { - assert.match(compose, /BAIYUE_DEPLOY_SLOT: \$\{DEPLOY_SLOT:-rolling\}/) - assert.match(compose, /BAIYUE_RELEASE_ID: \$\{RELEASE_ID:-local\}/) - assert.match(compose, /SERVER_SHUTDOWN: graceful/) - assert.match(compose, /stop_grace_period: \$\{STOP_GRACE_PERIOD:-45s\}/) - assert.match(compose, /\$\{DEPLOY_SLOT_SUFFIX:-\}/) - } -}) - -test('every scheduled worker service can disable Nacos registration', async () => { - for (const application of [ - 'iam/src/main/resources/application.yml', - 'firstleg/src/main/resources/application.yml', - 'finance/src/main/resources/application.yml', - 'order/src/main/resources/application.yml', - 'file/src/main/resources/application.yml', - 'event-bridge-service/src/main/resources/application.yml', - 'audit-service/src/main/resources/application.yml', - 'lastleg-service/src/main/resources/application.yml' - ]) { - assert.match(await read(application), /register-enabled: \$\{NACOS_REGISTER_ENABLED:true\}/, application) - } -}) - -test('production single-service pipeline dispatches the blue-green executor', async () => { - const deploy = await read('deploy/scripts/deploy-built-service.sh') - const batch = await read('deploy/scripts/deploy-services.sh') - assert.match(deploy, /run_scp "deploy\/scripts\/deploy-blue-green-service\.sh"/) - assert.match(deploy, /if \[ "\$DEPLOY_ENV" = "production" \]/) - assert.match(deploy, /run_ssh sh "\$REMOTE_DIR\/\$REMOTE_BLUE_GREEN_NAME"/) - assert.match(deploy, /TARGET_IS_LOCAL[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /sh deploy\/scripts\/deploy-blue-green-service\.sh/) -}) - -test('test environment uses single-slot compose updates', async () => { - const deploy = await read('deploy/scripts/deploy-built-service.sh') - const batch = await read('deploy/scripts/deploy-services.sh') - assert.doesNotMatch(deploy, /if \[ "\$DEPLOY_ENV" = "test" \] \\\n\s*\|\|[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /if \[ "\$DEPLOY_ENV" = test \]; then[\s\S]*?up -d --no-deps --pull never "\$service"/) -}) diff --git a/scripts/blue-green-deployment-runtime.test.mjs b/scripts/blue-green-deployment-runtime.test.mjs deleted file mode 100644 index 6e4a7026a..000000000 --- a/scripts/blue-green-deployment-runtime.test.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import assert from 'node:assert/strict' -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { delimiter, join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { spawnSync } from 'node:child_process' -import test from 'node:test' - -const root = resolve(import.meta.dirname, '..') -const script = resolve(root, 'deploy/scripts/deploy-blue-green-service.sh') -const shell = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : '/bin/sh' - -const dockerMock = `#!/usr/bin/env sh -set -eu -printf '%s\\n' "$*" >> "$MOCK_LOG" -command="$1" -shift -case "$command" in - network) - [ "$1" = inspect ] && { echo '{}'; exit 0; } - exit 0 - ;; - compose) - container_name='' - previous='' - for argument in "$@"; do - if [ "$previous" = --name ]; then container_name="$argument"; fi - previous="$argument" - if [ "$argument" = run ]; then - action=run - fi - done - if [ "\${action:-}" = run ]; then - case "$container_name" in - *-worker) : > "$MOCK_WORKER_EXISTS" ;; - *) : > "$MOCK_CANDIDATE_EXISTS" ;; - esac - exit 0 - fi - exit 0 - ;; - inspect) - target='' - for argument in "$@"; do target="$argument"; done - case "$target" in - firstleg-production-file) - [ ! -f "$MOCK_LEGACY_REMOVED" ] - ;; - firstleg-production-file-green) - [ -f "$MOCK_CANDIDATE_EXISTS" ] || exit 1 - case "$*" in - *State.Health*) printf '%s\\n' "\${MOCK_CANDIDATE_HEALTH:-healthy}" ;; - *Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;; - esac - ;; - firstleg-production-file-green-worker) - [ -f "$MOCK_WORKER_EXISTS" ] || exit 1 - case "$*" in - *State.Health*) printf '%s\\n' healthy ;; - *Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;; - esac - ;; - *) exit 1 ;; - esac - ;; - stop) - target='' - for argument in "$@"; do target="$argument"; done - [ "$target" = firstleg-production-file ] && : > "$MOCK_LEGACY_STOPPED" - ;; - rm) - target='' - for argument in "$@"; do target="$argument"; done - case "$target" in - firstleg-production-file) : > "$MOCK_LEGACY_REMOVED" ;; - firstleg-production-file-green) rm -f "$MOCK_CANDIDATE_EXISTS" ;; - firstleg-production-file-green-worker) rm -f "$MOCK_WORKER_EXISTS" ;; - esac - ;; - logs) echo 'candidate logs' ;; - *) echo "unexpected docker command: $command $*" >&2; exit 2 ;; -esac -` - -async function runBlueGreen(health) { - const temp = await mkdtemp(join(tmpdir(), 'blue-green-runtime-')) - const bin = join(temp, 'bin') - const docker = join(bin, 'docker') - const sleep = join(bin, 'sleep') - const envFile = join(temp, 'deployment.env') - const compose = join(temp, 'compose.yml') - const stateRoot = join(temp, 'state') - const log = join(temp, 'docker.log') - await mkdir(bin) - await mkdir(stateRoot) - await writeFile(docker, dockerMock, 'utf8') - await writeFile(sleep, '#!/usr/bin/env sh\nexit 0\n', 'utf8') - await chmod(docker, 0o755) - await chmod(sleep, 0o755) - await writeFile(envFile, 'FILE_EXPORT_CONSUMER_ENABLED=true\n', 'utf8') - await writeFile(compose, 'services: {}\n', 'utf8') - await writeFile(log, '', 'utf8') - - const paths = { - candidate: join(temp, 'candidate'), stopped: join(temp, 'legacy-stopped'), - removed: join(temp, 'legacy-removed'), worker: join(temp, 'worker') - } - const result = spawnSync(shell, [ - script.replaceAll('\\', '/'), 'production', 'registry.local/group', 'newtag', 'file', - envFile.replaceAll('\\', '/'), compose.replaceAll('\\', '/'), 'firstleg-production' - ], { - cwd: root, encoding: 'utf8', env: { - ...process.env, - PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, - BLUE_GREEN_STATE_ROOT: stateRoot, - BLUE_GREEN_STABILIZATION_SECONDS: '0', - BLUE_GREEN_DRAIN_SECONDS: '0', - DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', - DEPLOY_HEALTH_POLL_SECONDS: '1', - MOCK_LOG: log, - MOCK_CANDIDATE_EXISTS: paths.candidate, - MOCK_WORKER_EXISTS: paths.worker, - MOCK_LEGACY_STOPPED: paths.stopped, - MOCK_LEGACY_REMOVED: paths.removed, - MOCK_CANDIDATE_HEALTH: health - } - }) - const output = `${result.stdout}\n${result.stderr}` - const dockerLog = await readFile(log, 'utf8') - const stateFile = join(stateRoot, 'firstleg-production-file.active') - const state = existsSync(stateFile) ? await readFile(stateFile, 'utf8') : '' - const evidence = { - ...result, output, dockerLog, state, - stopped: existsSync(paths.stopped), removed: existsSync(paths.removed), - worker: existsSync(paths.worker) - } - await rm(temp, { recursive: true, force: true }) - return evidence -} - -test('healthy candidate becomes active only after health and old instance drains', async (context) => { - if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) - const result = await runBlueGreen('healthy') - assert.equal(result.status, 0, result.output) - assert.equal(result.stopped, true) - assert.equal(result.removed, true) - assert.equal(result.worker, true) - assert.match(result.state, /^green\|firstleg-production-file-green\|19206\|registry\.local\/group\/file:newtag\|firstleg-production-file-green-worker/) - const run = result.dockerLog.indexOf('compose --project-name firstleg-production') - const stop = result.dockerLog.indexOf('stop --time 45 firstleg-production-file') - assert.ok(run >= 0 && run < stop) - assert.match(result.dockerLog, - /network connect --alias file --alias file-service firstleg-production-routing firstleg-production-file-green/) -}) - -test('unhealthy candidate is removed while old instance and state remain untouched', async (context) => { - if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) - const result = await runBlueGreen('unhealthy') - assert.equal(result.status, 1, result.output) - assert.equal(result.stopped, false) - assert.equal(result.removed, false) - assert.equal(result.worker, false) - assert.equal(result.state, '') - assert.doesNotMatch(result.dockerLog, /stop --time .*firstleg-production-file/) -}) diff --git a/scripts/container-logging-contract.test.mjs b/scripts/container-logging-contract.test.mjs index 3fa84cdc5..4c2d75a61 100644 --- a/scripts/container-logging-contract.test.mjs +++ b/scripts/container-logging-contract.test.mjs @@ -5,12 +5,10 @@ import { test } from 'node:test'; const applicationConfigs = [ 'auth/src/main/resources/application.yml', 'audit-service/src/main/resources/application.yml', - 'event-bridge-service/src/main/resources/application.yml', 'file/src/main/resources/application.yml', 'finance/src/main/resources/application.yml', 'firstleg/src/main/resources/application.yml', 'gateway/src/main/resources/application.yml', - 'gateway-overseas/src/main/resources/application.yml', 'iam/src/main/resources/application.yml', 'integration-service/src/main/resources/application.yml', 'lastleg-service/src/main/resources/application.yml', @@ -18,14 +16,11 @@ const applicationConfigs = [ 'print/src/main/resources/application.yml', 'warehouse/src/main/resources/application.yml', 'warehouse-overseas-service/src/main/resources/application.yml', - 'config/nacos/cn/baiyue-common.yml', - 'config/nacos/local/baiyue-common.yml', - 'config/nacos/overseas/baiyue-common.yml', + 'config/nacos/test/baiyue-common.yml', ]; const composeFiles = [ 'deploy/docker-compose.yml', - 'deploy/docker-compose.overseas.yml', ]; const infrastructureComposeFiles = [ diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 5af5926f3..e5cb45c1d 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -110,24 +110,25 @@ test('constraint tightening is guarded before any alter table', async () => { } }) -test('数据库迁移计划包含本批四个 schema 的正式目标版本', async () => { +test('数据库迁移计划包含授权版本和事件可靠投递的正式目标版本', async () => { const sqlRoot = path.join(repositoryRoot, 'deploy', 'sql') const scopes = await loadScopes(path.join(sqlRoot, 'migration-scopes.json')) const plan = await buildPlan({ sqlRoot, scopes, regions: ['CN', 'OVERSEAS'], - services: ['iam', 'firstleg', 'lastleg-service', 'warehouse-overseas-service'], + services: ['iam', 'firstleg', 'finance', 'lastleg-service', 'warehouse-overseas-service'], commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) assert.equal(targets.baiyue_iam.target, '3') - assert.equal(targets.baiyue_firstleg.target, '4') - assert.equal(targets.baiyue_lastleg.target, '5') + assert.equal(targets.baiyue_firstleg.target, '6') + assert.equal(targets.baiyue_finance.target, '2') + assert.equal(targets.baiyue_lastleg.target, '7') assert.equal(targets.baiyue_lastlag_warehouse.target, '2') assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ - 'migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', - 'migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', 'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', + 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', + 'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql', ]) }) diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index e794a23d3..cb77b3b15 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -45,7 +45,7 @@ case "$command" in printf '%s\\n' starting > "$MOCK_HEALTH_STATE" printf '%s\\n' restarting > "$MOCK_RUNTIME_STATE" else - printf '%s\\n' healthy > "$MOCK_HEALTH_STATE" + printf '%s\\n' "\${MOCK_HEALTH_SUCCESS:-healthy}" > "$MOCK_HEALTH_STATE" printf '%s\\n' running > "$MOCK_RUNTIME_STATE" fi ;; @@ -69,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag }) { +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -82,7 +82,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting await mkdir(mockBin) await writeFile(dockerPath, mockDocker, 'utf8') await chmod(dockerPath, 0o755) - await writeFile(envFile, 'PLACEHOLDER=true\n', 'utf8') + await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876','MQ_TOPIC_PREFIX=test-','AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8') await writeFile(logFile, '', 'utf8') await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8') await writeFile(healthStateFile, 'healthy\n', 'utf8') @@ -90,10 +90,10 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting const result = spawnSync(shell, [ deployScript.replaceAll('\\', '/'), - 'development', + environment, 'registry.local/group/firstleg', imageTag, - 'audit-service', + service, envFile.replaceAll('\\', '/') ], { cwd: repositoryRoot, @@ -102,10 +102,12 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting ...process.env, PATH: `${mockBin}${delimiter}${process.env.PATH ?? ''}`, TARGET_IS_LOCAL: 'true', + ALLOW_SAME_CONTRACT_ROLLBACK: String(allowRollback), REGISTRY_USER: 'ci-user', REGISTRY_PASSWORD: 'ci-password', DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', DEPLOY_HEALTH_POLL_SECONDS: '1', + MOCK_HEALTH_SUCCESS: missingHealth ? 'no-health:running' : 'healthy', MOCK_LOG: logFile, MOCK_CURRENT_IMAGE: currentImageFile, MOCK_HEALTH_STATE: healthStateFile, @@ -173,3 +175,28 @@ test('restart loops fail fast and restore the previous image', async (context) = assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') }) + +test('architecture switch failure does not roll back one old service', async () => { + const result = await runDeployment({imageTag:'newbad', failingTag:'newbad', allowRollback:false}) + assert.equal(result.status,1) + assert.doesNotMatch(result.dockerLog,/pull .*:oldgood/) + assert.match(result.stderr,/Automatic rollback disabled/) +}) +test('non TEST environments and retired services fail before Docker', async () => { + for(const options of [{environment:'production'},{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){ + const result=await runDeployment({imageTag:'newgood',...options}) + assert.equal(result.status,2) + assert.equal(result.dockerLog,'') + } +}) + +test('running without HEALTHCHECK cannot be counted as deployed',async()=>{ + const result=await runDeployment({imageTag:'newgood',missingHealth:true,allowRollback:false}) + assert.equal(result.status,1) + assert.match(result.stderr,/Missing HEALTHCHECK/) +}) +test('SQL initializer rejects absent authorization before any Docker call',()=>{ + const result=spawnSync(shell,[resolve(repositoryRoot,'deploy/scripts/initialize-test-databases.sh').replaceAll('\\','/')],{encoding:'utf8',env:{...process.env,ALLOW_TEST_SCHEMA_INITIALIZATION:'false'}}) + assert.equal(result.status,2) + assert.match(result.stderr,/Explicit TEST SQL authorization required/) +}) diff --git a/scripts/event-outbox-operations.mjs b/scripts/event-outbox-operations.mjs new file mode 100644 index 000000000..2c64ea0ab --- /dev/null +++ b/scripts/event-outbox-operations.mjs @@ -0,0 +1,50 @@ +import { parseArgs } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function buildRequest(argv, env) { + const { values: args } = parseArgs({ args: argv, options: { + help: { type: 'boolean' }, service: { type: 'string' }, base: { type: 'string' }, + entry: { type: 'string' }, retry: { type: 'boolean' }, 'request-id': { type: 'string' }, + operator: { type: 'string' }, reason: { type: 'string' }, hash: { type: 'string' }, + } }) + if (args.help) return null + if (env.CHONGCHONG_ENV !== 'test') throw new Error('CHONGCHONG_ENV must be test') + if (!['firstleg', 'lastleg', 'finance'].includes(args.service)) throw new Error('Unknown producer service') + if (!/^[A-Za-z0-9-]{1,64}$/.test(args.entry ?? '')) throw new Error('Invalid entry ID') + const base = new URL(args.base) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || base.pathname !== '/') { + throw new Error('Base must be an explicit service origin without credentials or path') + } + const token = env.EVENT_OPERATIONS_TOKEN + if (!token || Buffer.byteLength(token) < 32) throw new Error('EVENT_OPERATIONS_TOKEN must contain at least 32 bytes') + const url = new URL(`/internal/${args.service}/event-operations/${args.entry}${args.retry ? '/retry' : ''}`, base) + const options = { method: args.retry ? 'POST' : 'GET', redirect: 'error', + headers: { 'X-Event-Operations-Token': token, 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(15000) } + if (args.retry) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(args['request-id'] ?? '')) throw new Error('A stable request UUID is required') + for (const [name, limit] of [['operator', 128], ['reason', 500]]) { + if (!args[name]?.trim() || args[name].length > limit || /[\x00-\x1f\x7f-\x9f]/.test(args[name])) throw new Error(`Invalid ${name}`) + } + if (!/^sha256:[0-9a-f]{64}$/i.test(args.hash ?? '')) throw new Error('Expected payload hash is required') + options.body = JSON.stringify({ requestId: args['request-id'], operator: args.operator, reason: args.reason, expectedPayloadHash: args.hash }) + } + return { url, options } +} + +async function main() { + const request = buildRequest(process.argv.slice(2), process.env) + if (!request) { + console.log('Inspect: --service firstleg|lastleg|finance --base --entry \nRetry: add --retry --request-id --operator --reason --hash \nEnvironment: CHONGCHONG_ENV=test, EVENT_OPERATIONS_TOKEN. No automatic retry or redirects. Retry writes Outbox state and audit atomically.') + return + } + const response = await fetch(request.url, request.options) + if (!response.ok) throw new Error(`Operations endpoint returned HTTP ${response.status}`) + const result = await response.json() + if (result?.code !== '200') throw new Error(`Operations rejected: code=${String(result?.code ?? 'missing')}`) + console.log(JSON.stringify(result.data, null, 2)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1 }) +} diff --git a/scripts/event-outbox-operations.test.mjs b/scripts/event-outbox-operations.test.mjs new file mode 100644 index 000000000..20a1cadba --- /dev/null +++ b/scripts/event-outbox-operations.test.mjs @@ -0,0 +1,23 @@ +import { buildRequest } from './event-outbox-operations.mjs' +import test from 'node:test' +import assert from 'node:assert/strict' +const env = { CHONGCHONG_ENV: 'test', EVENT_OPERATIONS_TOKEN: 't'.repeat(40) } +const args = ['--service', 'finance', '--base', 'http://127.0.0.1:9200', '--entry', 'event-123'] +test('inspection stays on explicit internal producer origin without redirects', () => { + const request = buildRequest(args, env) + assert.equal(request.url.href, 'http://127.0.0.1:9200/internal/finance/event-operations/event-123') + assert.equal(request.options.method, 'GET') + assert.equal(request.options.redirect, 'error') + assert.equal(request.options.body, undefined) +}) +test('retry requires stable identity, reason and expected payload hash', () => { + assert.throws(() => buildRequest([...args, '--retry'], env), /UUID/) + const request = buildRequest([...args, '--retry', '--request-id', '11111111-1111-4111-8111-111111111111', '--operator', 'operator-7', '--reason', 'dependency restored', '--hash', 'sha256:' + 'a'.repeat(64)], env) + assert.equal(request.options.method, 'POST') + assert.equal(JSON.parse(request.options.body).operator, 'operator-7') +}) +test('wrong environment, weak credential and unknown service fail before HTTP', () => { + assert.throws(() => buildRequest(args, { ...env, CHONGCHONG_ENV: 'prod' }), /must be test/) + assert.throws(() => buildRequest(args, { ...env, EVENT_OPERATIONS_TOKEN: '' }), /TOKEN/) + assert.throws(() => buildRequest(['--service', 'bridge'], env), /Unknown/) +}) diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 17a496a53..a5664120d 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -9,17 +9,17 @@ test('Jenkins pipelines build finance as an independent service', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(controller, /'finance': 'SERVICE_FINANCE'/) assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) - assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) }) test('Compose and deploy script expose finance with fail-closed database config', async () => { const compose = await read('deploy/docker-compose.yml') const serviceMap = await read('deploy/service-map.yml') - const deployScript = await read('deploy/scripts/deploy-services.sh') + const deployScript = await read('deploy/scripts/deploy-built-service.sh') assert.match(compose, /^ finance:\s*$/m) assert.match(compose, /image: \$\{IMAGE_REPOSITORY\}\/finance:\$\{IMAGE_TAG\}/) @@ -28,5 +28,5 @@ test('Compose and deploy script expose finance with fail-closed database config' assert.doesNotMatch(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT/) assert.match(serviceMap, /^ finance:\s*$/m) assert.match(serviceMap, /jar: finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(deployScript, /FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD/) + assert.match(deployScript, /for suffix in URL USERNAME PASSWORD/) }) diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 31f96cf24..f421241d8 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { +test('root POM freezes 20 modules and service-map freezes 13 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 22) - assert.equal(new Set(modules).size, 22) - assert.equal(services.size, 15) + assert.equal(modules.length, 20) + assert.equal(new Set(modules).size, 20) + assert.equal(services.size, 13) const bootModules = [] for (const module of modules) { @@ -181,24 +181,13 @@ test('SpotBugs remains an opt-in non-blocking report without quality thresholds' assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) }) -test('regional Compose and Nacos manifests cover the service-map topology', async () => { +test('one TEST Compose and Nacos manifest cover every retained service', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) - const composeByRegion = new Map([ - ['CN', parseCompose(await read('deploy/docker-compose.yml'))], - ['OVERSEAS', parseCompose(await read('deploy/docker-compose.overseas.yml'))] - ]) - + const compose = parseCompose(await read('deploy/docker-compose.yml')) + assert.deepEqual(sorted(compose.keys()), sorted(services.keys())) + assert.equal(services.size, 13) for (const [module, service] of services) { - const regions = service.regions ?? ['CN'] - for (const [region, compose] of composeByRegion) { - assert.equal(compose.has(module), regions.includes(region), `${module} Compose region drifted for ${region}`) - if (!regions.includes(region)) continue - assert.equal(compose.get(module), service.port, `${module} internal port drifted for ${region}`) - await access(new URL(`../config/nacos/${region.toLowerCase()}/${service.artifactId}.yml`, import.meta.url)) - } + assert.equal(compose.get(module), service.port, module) + await access(new URL(`../config/nacos/test/${service.artifactId}.yml`, import.meta.url)) } - assert.deepEqual( - sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))), - sorted(services.keys()) - ) }) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 4f294563b..e3a9c9c24 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -1,204 +1,53 @@ import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { readFileSync, existsSync, readdirSync } from 'node:fs' import test from 'node:test' +const read = p => readFileSync(new URL('../' + p, import.meta.url), 'utf8').replaceAll('\r\n','\n') +const exists = p => existsSync(new URL('../' + p, import.meta.url)) -const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('local, development and test Nacos publishing remain environment-isolated', async () => { - const publisher = await read('scripts/publish-nacos-config.ps1') - const local = [ - await read('config/nacos/local/gateway-overseas.yml'), - await read('config/nacos/local/lastleg-service.yml'), - await read('config/nacos/local/warehouse-overseas-service.yml'), - ].join('\n') - const remote = [ - await read('config/nacos/overseas/gateway-overseas.yml'), - await read('config/nacos/overseas/lastleg-service.yml'), - await read('config/nacos/overseas/warehouse-overseas-service.yml'), - ].join('\n') - - assert.match(publisher, /ValidateSet\('local', 'dev', 'development', 'test', 'production'\)/) - assert.match(publisher, /必须显式指定非 public 的 Nacos namespace/) - assert.match(local, /127\.0\.0\.1:9100/) - assert.match(local, /127\.0\.0\.1:9303/) - assert.doesNotMatch(remote, /127\.0\.0\.1|password:\s*123456/) - assert.match(remote, /http:\/\/lastleg-service:9303/) - assert.match(remote, /\$\{LASTLEG_DB_URL\}/) - assert.match(remote, /\$\{WAREHOUSE_DB_URL\}/) +test('TEST has one complete Compose, namespace, session and no retired deployment entry', () => { + const compose=read('deploy/docker-compose.yml') + for(const service of ['gateway','auth','iam','firstleg','finance','order','warehouse','file','print','integration-service','lastleg-service','warehouse-overseas-service','audit-service']) assert.match(compose,new RegExp('^ '+service+':','m')) + assert.doesNotMatch(compose,/event-bridge|gateway-overseas|OVERSEAS_REDIS|OVERSEAS_NACOS|OVERSEAS_JWT|JWT_SECRET|test-cn|test-overseas/) + assert.match(compose,/SPRING_PROFILES_ACTIVE: test/) + assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual TEST namespace ID required\}/) + assert.match(compose,/REDIS_DATABASE: \$\{REDIS_DATABASE:-0\}/) + assert.match(compose,/SPRING_DATA_REDIS_USERNAME: \$\{REDIS_USERNAME:-default\}/) + assert.match(compose,/JWT_JWKS_URI: http:\/\/auth:9100\/api\/auth\/\.well-known\/jwks.json/) + assert.equal([...compose.matchAll(/^ ports:/gm)].length,1) + assert.equal([...compose.matchAll(/^ JWT_RSA_JWK_SET:/gm)].length,1) + assert.equal([...compose.matchAll(/^ EVENT_OPERATIONS_TOKEN:/gm)].length,3) + assert.match(compose,/LASTLEG_SERVICE_URL: http:\/\/lastleg-service:9303/) + assert.match(compose,/FIRSTLEG_SERVICE_URL: http:\/\/firstleg:9202/) + assert.match(compose,/FINANCE_SERVICE_URL: http:\/\/finance:9203/) + assert.match(compose,/FILE_UPLOADS_HOST_DIR/) + assert.match(compose,/ROCKETMQ_NAME_SERVER/) + for(const p of ['deploy/docker-compose.overseas.yml','deploy/scripts/deploy-blue-green-service.sh','deploy/scripts/deploy-development-service.sh']) assert.equal(exists(p),false,p) }) - -test('Jenkins pipelines recognize all overseas deployable modules', async () => { - const controller = await read('Jenkinsfile') - const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { - assert.match(controller, new RegExp(`'${module}'`)) - assert.match(service, new RegExp(`'${module}'`)) - } - assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) - assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) - assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) - assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) - assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/) - assert.doesNotMatch(service, /overseas-security-service/) - assert.match(controller, /EVENT_BRIDGE_REGION/) - assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) - assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) - assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.doesNotMatch(controller, /事件桥 BOTH 仅支持开发或测试环境/) - assert.match(service, /部署生产事件桥实例:/) +test('every retained application imports TEST configuration with explicit namespace and no SQL auto init',()=>{ + const services=[...read('deploy/service-map.yml').matchAll(/^ ([a-z][a-z-]+):/gm)].map(m=>m[1]) + for(const service of services){ + const app=read(`${service}/src/main/resources/application.yml`) + assert.match(app,/nacos:baiyue-common.yml\?group=\$\{NACOS_GROUP:BAIYUE\}/) + assert.match(app,/\$\{NACOS_NAMESPACE\}/) + assert.doesNotMatch(app,/optional:nacos|NACOS_NAMESPACE:public|mode: always|EVENT_BRIDGE|JWT_SECRET|REDIS_PORT:6380/) + } + const common=read('config/nacos/test/baiyue-common.yml') + assert.match(common,/jwt-mode: jwks/) + assert.match(common,/session-mode: redis/) + assert.match(common,/session-validation-uri: \$\{AUTH_SESSION_VALIDATION_URL:http:\/\/auth:9100\/internal\/auth\/accounts\/sessions\/validate\}/) + assert.match(read('deploy/scripts/deploy-built-service.sh'),/Missing HEALTHCHECK; deployment failed/) }) - -test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { - const map = await read('deploy/service-map.yml') - assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) - assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) - assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) - assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) - assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.doesNotMatch(map, /overseas-security-service|9307/) - assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) -}) - -test('overseas compose is isolated from CN defaults and disables implicit schema execution', async () => { - const compose = await read('deploy/docker-compose.overseas.yml') - assert.match(compose, /^name: firstleg-\$\{DEPLOY_ENV:-test\}-overseas$/m) - assert.match(compose, /NACOS_USERNAME: \$\{OVERSEAS_NACOS_USERNAME\}/) - assert.match(compose, /NACOS_PASSWORD: \$\{OVERSEAS_NACOS_PASSWORD\}/) - assert.match(compose, /NACOS_NAMESPACE: \$\{OVERSEAS_NACOS_NAMESPACE:-prod-overseas\}/) - assert.match(compose, /REGION_CODE: OVERSEAS/) - assert.match(compose, /SPRING_SQL_INIT_MODE: never/) - assert.match(compose, /^ gateway-overseas:\s*$/m) - assert.match(compose, /^ lastleg-service:\s*$/m) - assert.match(compose, /^ warehouse-overseas-service:\s*$/m) - assert.match(compose, /^ event-bridge-service:\s*$/m) - assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-\$\{EVENT_BRIDGE_OVERSEAS_PORT:-9313\}}:9312/) - assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(compose, /LASTLEG_DB_URL: \$\{LASTLEG_DB_URL\}/) - assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) - assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) - assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) - assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) - assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) - assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FILE_SERVICE_URL: \$\{FILE_SERVICE_URL\}/) - assert.doesNotMatch(compose, /DOMESTIC_FILE_SERVICE_URL/) - assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) - assert.match(compose, /aliases: \[event-bridge-overseas\]/) -}) - -test('CN and overseas event bridges have explicit opposite-region consumers', async () => { - const cnCompose = await read('deploy/docker-compose.yml') - const overseasCompose = await read('deploy/docker-compose.overseas.yml') - const cn = await read('config/nacos/cn/event-bridge-service.yml') - const overseas = await read('config/nacos/overseas/event-bridge-service.yml') - const firstleg = await read('config/nacos/cn/firstleg-service.yml') - const iam = await read('config/nacos/cn/iam-service.yml') - const iamApplication = await read('iam/src/main/resources/application.yml') - const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') - const lastleg = await read('config/nacos/overseas/lastleg-service.yml') - const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') - const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` - assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) - assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) - assert.match(cnCompose, /EVENT_CONSUMER_URL: \$\{FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events\}/) - assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) - assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) - assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) - assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) - assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) - assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) - assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) - assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) - assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) - assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) - assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) - assert.match(lastleg, /domestic-file:\s*\n\s*url: \$\{FILE_SERVICE_URL\}/) - assert.doesNotMatch(lastleg, /DOMESTIC_FILE_SERVICE_URL/) - assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) - assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) - assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) - assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) - assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) - assert.match(scripts, /FILE_SERVICE_URL/) - assert.doesNotMatch(scripts, /DOMESTIC_FILE_SERVICE_URL/) - assert.match(scripts, /docker-compose\.overseas\.yml/) - assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/) - assert.match(scripts, /env_file_value REGION_CODE/) - assert.match(scripts, /EVENT_BRIDGE_REGION_OVERRIDE/) - assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) - assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) - assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) - assert.match(scripts, /运行容器镜像与请求镜像不一致/) - assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) - assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) - for (const key of [ - 'NACOS_SERVER_ADDR', 'NACOS_USERNAME', 'NACOS_PASSWORD', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', - 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' - ]) assert.match(scripts, new RegExp(key)) -}) - -test('domestic Auth receives the overseas JWT signing configuration', async () => { - const compose = await read('deploy/docker-compose.yml') - for (const key of [ - 'OVERSEAS_JWT_ENABLED', 'OVERSEAS_JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', - 'OVERSEAS_JWT_RSA_JWK_SET', 'OVERSEAS_JWT_CURRENT_KID', 'OVERSEAS_ACCESS_TOKEN_TTL' - ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) -}) - -test('domestic event bridge initialization defines customer columns without legacy migration', async () => { - const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') - const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) - for (const table of ['bridge_inbox', 'bridge_outbox']) { - const definition = incremental.match(new RegExp(`CREATE TABLE ${table} \\(([\\s\\S]*?)\\) ENGINE`))?.[1] - assert.ok(definition, `${table} must be defined by the empty-database baseline`) - assert.match(definition, /customer_id varchar\(128\)[^\n]*NOT NULL/i) - assert.doesNotMatch(definition, /\btenant_id\b/) - } - assert.doesNotMatch(incremental, /RENAME COLUMN tenant_id TO customer_id/) -}) - -test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { - const compose = await read('deploy/docker-compose.overseas.yml') - for (const service of ['lastleg-service', 'warehouse-overseas-service']) { - const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1] - assert.ok(block, `${service} must have a deployment definition`) - assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/, - `${service} cannot discover the CN Print service from its overseas Nacos namespace`) - const config = await read(`${service}/src/main/resources/application.yml`) - assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/) - } -}) - -test('domestic initialization defines the overseas hold version and comment in the table', async () => { - const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - const definition = incremental.match(/CREATE TABLE fl_inbound_order \(([\s\S]*?)\) ENGINE/)?.[1] - assert.ok(definition) - assert.match(definition, /last_overseas_hold_version bigint unsigned DEFAULT NULL COMMENT '[^']+'/i) - assert.doesNotMatch(incremental, /CALL (?:add_inbound_overseas_hold_version|queue_column_comment)\(/) -}) - -test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { - const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql') - assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) - assert.match(overseas, /USE baiyue_event_bridge_overseas;/) - for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { - assert.match(overseas, new RegExp(`CREATE TABLE ${table} \\(`)) - } - for (const index of [ - 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', - 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', - 'uk_bridge_attempt', 'idx_bridge_attempt_event' - ]) assert.match(overseas, new RegExp(index)) - assert.doesNotMatch(overseas, /CREATE TABLE IF NOT EXISTS/) - assert.match(overseas, /initialization_status/) - assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), - 'overseas Event Bridge schema must be initialized before Lastleg tables') +test('Jenkins TEST publication and Nacos import have no dual-region or production execution',()=>{ + for(const p of ['Jenkinsfile','Jenkinsfile.service']){ + const s=read(p) + assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'production'|return 'development'|firstleg-production-env|java-dev/) + assert.doesNotMatch(s,/maven.test.skip/) + } + assert.match(read('Jenkinsfile.service'),/node\('java-test'\)/) + assert.match(read('Jenkinsfile.service'),/ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false/) + const publisher=read('scripts/publish-nacos-config.ps1') + assert.match(publisher,/ValidateSet\('test'\)/) + assert.match(publisher,/config\/nacos\/test/) + assert.match(publisher,/\[switch\]\$DryRun/) + assert.match(publisher,/accessToken/) }) diff --git a/scripts/publish-local-nacos-config.ps1 b/scripts/publish-local-nacos-config.ps1 deleted file mode 100644 index 7f249c4ce..000000000 --- a/scripts/publish-local-nacos-config.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -param( - [string]$ServerAddr = '127.0.0.1:8848', - [string]$Group = 'BAIYUE', - [string]$Namespace = 'public' -) - -$ErrorActionPreference = 'Stop' -$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -$configRoot = Join-Path $projectRoot 'config\nacos\local' -$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" -$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" - -$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 -if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Nacos not ready: $healthUrl" -} - -$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name -if ($files.Count -eq 0) { - throw "No local Nacos configuration found: $configRoot" -} - -$published = 0 -foreach ($file in $files) { - $body = @{ - dataId = $file.Name - group = $Group - content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 - type = 'yaml' - } - if ($Namespace -and $Namespace -ne 'public') { - $body.tenant = $Namespace - } - - $pairs = foreach ($entry in $body.GetEnumerator()) { - [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) - } - $form = $pairs -join '&' - $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` - -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body $form -TimeoutSec 10 - if ([string]$result -ne 'true') { - throw "Publish failed: $($file.Name), Nacos response: $result" - } - $published++ - Write-Host "Published $($file.Name) -> $Namespace / $Group" -} - -Write-Host "Published local Nacos configuration: $published items." diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 index 0231e4130..6005bb2e6 100644 --- a/scripts/publish-nacos-config.ps1 +++ b/scripts/publish-nacos-config.ps1 @@ -1,68 +1,27 @@ param( - [ValidateSet('local', 'dev', 'development', 'test', 'production')] - [string]$Environment, - [ValidateSet('cn', 'overseas')] - [string]$Region = 'cn', - [string]$ServerAddr = '127.0.0.1:8848', + [ValidateSet('test')][string]$Environment = 'test', + [Parameter(Mandatory)][string]$ServerAddr, [string]$Group = 'BAIYUE', - [string]$Namespace + [Parameter(Mandatory)][string]$Namespace, + [string]$Username = $env:NACOS_USERNAME, + [string]$Password = $env:NACOS_PASSWORD, + [switch]$DryRun ) - $ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($Namespace) -or $Namespace -eq 'public') { throw 'Supply the actual TEST Namespace ID, not public or a display name' } $projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) - -if ($Environment -eq 'local') { - if ($Namespace -and $Namespace -ne 'public') { - throw 'local 环境只能发布到 public namespace' - } - $Namespace = 'public' - $configProfile = 'local' -} else { - if (-not $Namespace -or $Namespace -eq 'public') { - throw "$Environment/$Region 必须显式指定非 public 的 Nacos namespace" - } - $configProfile = $Region -} - -$configRoot = (Resolve-Path (Join-Path $projectRoot "config\nacos\$configProfile")).Path -$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" -$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" -$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 -if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Nacos not ready: $healthUrl" -} - -$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name -if ($files.Count -eq 0) { - throw "No Nacos configuration found: $configRoot" -} - -$published = 0 +$configRoot = Join-Path $projectRoot 'config/nacos/test' +$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object @{Expression={if ($_.Name -eq 'baiyue-common.yml') {0} else {1}}}, Name +if ($files.Count -ne 14) { throw 'Expected common configuration and 13 retained services' } +if ($DryRun) { $files | ForEach-Object { Write-Host "Would publish $($_.Name) -> $Namespace / $Group" }; return } +if (-not $Username -or -not $Password) { throw 'NACOS_USERNAME and NACOS_PASSWORD are required' } +$base = "http://$ServerAddr/nacos" +$login = Invoke-RestMethod -Method Post -Uri "$base/v1/auth/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 +if (-not $login.accessToken) { throw 'Nacos authentication failed' } foreach ($file in $files) { - $resolvedFile = (Resolve-Path -LiteralPath $file.FullName).Path - if (-not $resolvedFile.StartsWith($configRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Unexpected config path: $resolvedFile" - } - $body = @{ - dataId = $file.Name - group = $Group - content = Get-Content -LiteralPath $resolvedFile -Raw -Encoding UTF8 - type = 'yaml' - } - if ($Namespace -ne 'public') { - $body.tenant = $Namespace - } - $pairs = foreach ($entry in $body.GetEnumerator()) { - [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) - } - $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` - -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body ($pairs -join '&') -TimeoutSec 10 - if ([string]$result -ne 'true') { - throw "Publish failed: $($file.Name), Nacos response: $result" - } - $published++ + $body = @{dataId=$file.Name; group=$Group; tenant=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} + $pairs = foreach ($entry in $body.GetEnumerator()) { [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) } + $result = Invoke-RestMethod -Method Post -Uri "$base/v1/cs/configs" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 + if ([string]$result -ne 'true') { throw "Publish failed: $($file.Name)" } Write-Host "Published $($file.Name) -> $Namespace / $Group" } - -Write-Host "Published $Environment/$Region Nacos configuration: $published items." diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 977e42208..9c527c0ea 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,16 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java', + 'lastleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用及资金指令按eventId/hash直投对应Finance内部入口,严格DeliveryAck'), + http('common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', + 'common-security', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'Servlet认证读取权威会话状态;内部令牌、总超时、拒绝重定向、失败关闭'), + http('gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java', + 'gateway', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', '统一入口非阻塞查询权威会话;内部令牌与超时,失败关闭'), + http('firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java', + 'firstleg-service', 'external-lingxing-browser', ['EXTERNAL'], 'READ_WRITE', 'MIXED', 'CDP读取浏览器会话及执行登录/验证码;人工验证不可自动绕过'), + http('firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java', + 'firstleg-service', 'external-lingxing', ['EXTERNAL'], 'READ_WRITE', 'MIXED', '领星SKU查询与新品维护写入;已维护状态防止重复提交,不假定外部幂等'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java', 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '按作业仓与账号查询有效国内 PDA 操作人,内部服务令牌保护'), feign('order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java', @@ -54,15 +64,15 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), - feign('finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java', - 'finance-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), + feign('finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java', + 'finance-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), - feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', - 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java', + 'firstleg-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java', @@ -118,11 +128,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'IAM 持久化任务租约投递;初始化/补偿复用稳定 initializationKey,不在客户创建请求内调用'), feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), - feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', - 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), - feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', - 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java', + 'lastleg-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java', 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取国内共享仓库权威目录'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', @@ -159,18 +167,6 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java', - 'event-bridge-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '跨区费用 Inbox 按 eventId 投递 Finance'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', - 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', - 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', - 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), - http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', - 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), - http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index d94f07c7c..d4d269793 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -10,13 +10,11 @@ import { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const serviceModules = [ - 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', - 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service' -] +// Discover all active Maven modules; unreviewed clients must fail equality checks. +const serviceModules = [...(await readFile(path.join(root, 'pom.xml'), 'utf8')) + .matchAll(/([^<]+)<\/module>/g)].map(match => match[1]) -const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) +const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') || name.startsWith('common-') || name === 'gateway' || name === 'platform-bom' ? '' : '-service'}`])) const expectedOnlineEdges = [ 'auth-service->iam-service', @@ -47,25 +45,22 @@ const expectedOnlineEdges = [ ] const expectedBackgroundEdges = [ - 'event-bridge-service->finance-service', - 'event-bridge-service->firstleg-service', - 'event-bridge-service->lastleg-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', 'file-service->order-service', 'file-service->warehouse-service', - 'finance-service->event-bridge-service', + 'finance-service->lastleg-service', 'finance-service->firstleg-service', - 'firstleg-service->event-bridge-service', + 'firstleg-service->lastleg-service', 'firstleg-service->finance-service', 'firstleg-service->integration-service', 'firstleg-service->order-service', - 'iam-service->event-bridge-service', 'iam-service->firstleg-service', - 'lastleg-service->event-bridge-service', + 'lastleg-service->finance-service', + 'lastleg-service->firstleg-service', 'order-service->firstleg-service' -] +].sort() const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ @@ -78,7 +73,7 @@ async function javaFiles(directory) { else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target) } } - await visit(directory) + if (await readdir(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error })) await visit(directory) return result } @@ -111,17 +106,17 @@ test('all Feign clients have reviewed context, access and idempotency metadata', test('all raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] - for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { + for (const module of serviceModules) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { const source = await readFile(file, 'utf8') if (!rawHttpPattern.test(source)) continue - if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue + if (!/(?:private|final)\s+(?:[\w.]+\.)?(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue discovered.push(path.relative(root, file).replaceAll('\\', '/')) } } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 24) + assert.equal(reviewed.length, 22) assert.deepEqual(discovered.sort(), reviewed) }) @@ -175,7 +170,7 @@ test('all six Lastleg RestClient gateways use explicit bounded transports', asyn for (const configFile of [ 'lastleg-service/src/main/resources/application.yml', - 'config/nacos/overseas/lastleg-service.yml' + 'config/nacos/test/lastleg-service.yml' ]) { const source = await readFile(path.join(root, configFile), 'utf8') for (const key of ['unload-photo-file', 'relabel-photo-file', 'customer-visit-file', 'procurement-file', 'warehouse-resource', 'pallet-physical']) { @@ -191,26 +186,28 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ 'common-audit->audit-service', - 'gateway-overseas->auth-service', - 'gateway-overseas->lastleg-service' + 'common-security->auth-service', + 'gateway->auth-service' ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], ['finance-service', 'firstleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' + 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ - 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', + 'auth-service', 'file-service', 'finance-service', 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' ]]) }) -test('overseas security projection stays in Lastleg infrastructure and background edges', () => { - assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service')) - assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service')) - assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) +test('unified authority checks remain infrastructure edges and direct events remain background', () => { + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway->auth-service')) + assert.ok(graphEdges('INFRASTRUCTURE').includes('common-security->auth-service')) + assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) + assert.ok(!graphEdges('REQUEST').includes('lastleg-service->finance-service')) + 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', () => { @@ -284,7 +281,7 @@ test('known user entries expose every longest simple REQUEST path over the two-h }) test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { - for (const profile of ['local', 'cn', 'overseas']) { + for (const profile of ['test']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') assert.match(source, /connect-timeout:\s*3000/) assert.match(source, /read-timeout:\s*5000/) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 606d5925c..a23e788fb 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -14,34 +14,24 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', .replaceAll('\r\n', '\n') const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') -const overseasCompose = (await readFile(new URL('../deploy/docker-compose.overseas.yml', import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) }) -test('test deployments compile packages without rerunning the full unit test suite', () => { - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) +test('test compilation is preserved even when execution is explicitly skipped', () => { + assert.match(pipeline, /boolean skipTestExecution = params\.SKIP_TESTS/) assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/) assert.match(pipeline, /-pl \$\{env\.RESOLVED_SERVICE\} -am clean package/) assert.doesNotMatch(pipeline, /-Dmaven\.test\.skip=true/) }) test('coordinator and service jobs enforce their own deployment environments', () => { - assert.match(coordinatorPipeline, /case 'backend-java-dev':[\s\S]*?return 'development'/) - assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) - assert.match(coordinatorPipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) - assert.match(coordinatorPipeline, /environmentChoicesForJob\(env\.JOB_BASE_NAME\)/) - assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) - assert.match(coordinatorPipeline, /env\.RESOLVED_JOB_PREFIX = resolvedJobPrefix/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-dev-'\)[\s\S]*?return 'development'/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-prod-'\)[\s\S]*?return 'production'/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-'\)[\s\S]*?return 'test'/) - assert.match(pipeline, /environmentChoicesForServiceJob\(env\.JOB_BASE_NAME\)/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) - assert.doesNotMatch(pipeline, /params\.ENVIRONMENT ==/) - assert.doesNotMatch(pipeline, /contains\(params\.ENVIRONMENT\)/) + assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.doesNotMatch(coordinatorPipeline,/return 'production'|return 'development'/) + assert.match(pipeline,/startsWith\('baiyue-firstleg-'\)/) + assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-dev-'\)/) + assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-prod-'\)/) }) test('AUTO advances interrupted baselines and retries only their selected services', () => { @@ -72,67 +62,35 @@ test('AUTO maps shared modules to their real consumers instead of rebuilding eve assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/) }) -test('production deployments cannot skip tests', () => { - assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) - assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) - assert.match(pipeline, /生产环境手工构建禁止跳过测试/) - assert.match(coordinatorPipeline, /refs\/heads\/main:refs\/remotes\/origin\/main/) - assert.match(coordinatorPipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) - assert.match(pipeline, /git merge-base --is-ancestor "\$\{RESOLVED_SOURCE_COMMIT\}" origin\/main/) +test('TEST builds respect explicit test execution controls', () => { + assert.doesNotMatch(coordinatorPipeline,/backend-java-prod|return 'production'/) + assert.match(coordinatorPipeline,/!params.SKIP_TESTS/) + assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) }) test('Java builds use the shared builder pool and serialize incremental target deployments', () => { - assert.match(pipeline, /agent \{ label 'java-builder' \}/) - assert.match(pipeline, /\['development', 'test'\]\.contains\(env\.RESOLVED_ENVIRONMENT\)/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) - assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-deploy-slot"/) - assert.match(pipeline, /lock\(label: deploySlotLabel, quantity: 1, inversePrecedence: true\)/) - assert.match(pipeline, /deploy-built-service\.sh/) - assert.doesNotMatch(pipeline, /dev_deploy|test_deploy/) - assert.match(pipeline, /firstleg-production-ssh/) - assert.match(pipeline, /TARGET_SSH_KEY/) - assert.match(directDeployScript, /docker pull "\$IMAGE"/) - assert.match(directDeployScript, /run_ssh docker pull "\$IMAGE"/) - assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) - assert.match(directDeployScript, /up -d --no-deps --pull never/) - assert.doesNotMatch(directDeployScript, /--remove-orphans/) - assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) - assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) - assert.match(directDeployScript, /--pull never/) + assert.match(pipeline,/agent \{ label 'java-builder' \}/) + assert.match(pipeline,/node\('java-test'\)/) + assert.match(pipeline,/lock\(resource: "firstleg-test-\$\{env.RESOLVED_SERVICE\}-deploy"/) + assert.match(pipeline,/'TARGET_IS_LOCAL=true'/) + assert.match(directDeployScript,/up -d --no-deps --pull never/) + assert.doesNotMatch(directDeployScript,/--remove-orphans|run_ssh|docker save/) }) -test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { - assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) - assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +test('downstream skips tests only by explicit flag and keeps test compilation',()=>{ + assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) + assert.doesNotMatch(pipeline,/maven.test.skip/) }) -test('development and test deployment locks are acquired before target executors', () => { - const directDeployStage = pipeline.slice( - pipeline.indexOf("stage('部署开发或测试环境')"), - pipeline.indexOf("stage('部署单个服务')") - ) - const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') - const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') - const nodeIndex = directDeployStage.indexOf('node(targetLabel)') - - assert.match(directDeployStage, /agent none/) - assert.match(directDeployStage, /String targetLabel = env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) - assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') - assert.ok(lockIndex >= 0, 'deployment slot lock must exist') - assert.ok(nodeIndex >= 0, 'target node allocation must exist') - assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') - assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') +test('TEST deployment lock is acquired before target executor',()=>{ + const stage=pipeline.slice(pipeline.indexOf("stage('部署 TEST')")) + assert.match(stage,/agent none/) + assert.ok(stage.indexOf('lock(resource:') < stage.indexOf("node('java-test')")) }) -test('development publishes every service on loopback without changing test defaults', () => { - assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PUBLISHED_PORT:-\$\{GATEWAY_PORT:-9000\}\}:9000/) - assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PUBLISHED_PORT:-\$\{GATEWAY_OVERSEAS_PORT:-9300\}\}:9300/) - assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) +test('only Gateway publishes to host loopback',()=>{ + assert.equal([...serviceCompose.matchAll(/^ ports:/gm)].length,1) + assert.match(directDeployScript,/export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) }) test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => { @@ -158,7 +116,7 @@ test('Firstleg deployment passes terminal drayage SMTP settings into the contain test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), - pipeline.indexOf("stage('部署开发或测试环境')") + pipeline.indexOf("stage('部署 TEST')") ) const loginIndex = imageStage.indexOf('docker login') const buildIndex = imageStage.indexOf('docker build') @@ -175,25 +133,11 @@ test('parallel image publications isolate registry credentials per Jenkins job', assert.doesNotMatch(imageStage, /\/home\/deploy\/\.docker\/config\.json/) }) -test('parallel remote deployments keep env and compose assets isolated by project', () => { - assert.match(directDeployScript, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) - assert.match(directDeployScript, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) - assert.match(directDeployScript, /REMOTE_ENV_TMP="\$REMOTE_DIR\/\$REMOTE_ENV_NAME\.\$SUFFIX\.tmp"/) - assert.match(directDeployScript, /REMOTE_COMPOSE_TMP="\$REMOTE_DIR\/\$REMOTE_COMPOSE_NAME\.\$SUFFIX\.tmp"/) - assert.match(directDeployScript, /env_target="\$4"/) - assert.match(directDeployScript, /compose_target="\$5"/) - assert.match(directDeployScript, /blue_green_target="\$6"/) - assert.match(directDeployScript, /mv -f "\$env_tmp" "\$env_target"/) - assert.match(directDeployScript, /mv -f "\$compose_tmp" "\$compose_target"/) - assert.match(directDeployScript, /mv -f "\$blue_green_tmp" "\$blue_green_target"/) - assert.match(directDeployScript, /env_file="\$7"/) - assert.match(directDeployScript, /compose_file="\$8"/) - assert.match(directDeployScript, /--env-file "\$env_file"/) - assert.match(directDeployScript, /-f "\$compose_file"/) - assert.doesNotMatch(directDeployScript, /mv -f "\$env_tmp" "\$remote_dir\/\.env"/) - assert.doesNotMatch(directDeployScript, /mv -f "\$compose_tmp" "\$remote_dir\/docker-compose\.yml"/) - assert.doesNotMatch(directDeployScript, /--env-file \.env/) - assert.doesNotMatch(directDeployScript, /-f docker-compose\.yml/) +test('TEST deployments use explicit local env and Compose files', () => { + assert.match(directDeployScript,/TEST deploy must run on the target host/) + assert.match(directDeployScript,/--env-file "\$ENV_FILE"/) + assert.match(directDeployScript,/-f "\$COMPOSE_FILE"/) + assert.doesNotMatch(directDeployScript,/scp|run_ssh/) }) test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => { @@ -202,8 +146,8 @@ test('shared builders use a reachable public Maven mirror instead of the retired assert.doesNotMatch(ciMavenSettings, /:18081/) }) -test('Java service images use the canonical Gitea registry base image by default', () => { - const internalBaseImage = 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine' +test('Java service images use configurable Java 17 base images', () => { + const internalBaseImage = 'eclipse-temurin:17-jre-alpine' assert.match(coordinatorPipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) assert.match(pipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) @@ -214,20 +158,10 @@ test('Java service images use the canonical Gitea registry base image by default }) test('source workspaces are preserved for incremental Git fetches', () => { - const buildStage = pipeline.slice( - pipeline.indexOf("stage('构建并发布单服务')"), - pipeline.indexOf("stage('部署开发或测试环境')") - ) - const directDeployStage = pipeline.slice( - pipeline.indexOf("stage('部署开发或测试环境')"), - pipeline.indexOf("stage('部署单个服务')") - ) - - assert.match(pipeline, /checkout scm/) - assert.doesNotMatch(buildStage, /cleanWs\(/) - assert.match(directDeployStage, /cleanWs\(/) - assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) - assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) + const buildStage=pipeline.slice(0,pipeline.indexOf("stage('部署 TEST')")) + assert.match(buildStage,/checkout scm/) + assert.doesNotMatch(buildStage,/cleanWs\(|deleteDir\(/) + assert.match(pipeline.slice(pipeline.indexOf("stage('部署 TEST')")),/deleteDir\(\)/) }) test('direct deployments wait for health and automatically restore the previous image', () => { @@ -240,15 +174,7 @@ test('direct deployments wait for health and automatically restore the previous assert.match(directDeployScript, /return 1/) }) -test('one event bridge build deploys both regional instances in every environment', () => { - assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) - assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) - assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) - assert.match(pipeline, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) - assert.match(pipeline, /部署事件桥实例:/) - assert.match(pipeline, /imageTag\?\.endsWith\('-both'\)/) - assert.match(pipeline, /imageTag\.substring\(0, imageTag\.length\(\) - '-both'\.length\(\)\)/) - assert.match(pipeline, /env\.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges \? 'true' : 'false'/) - assert.match(pipeline, /部署生产事件桥实例:/) +test('retired bridge jobs cannot be dispatched', () => { + assert.doesNotMatch(pipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) + assert.doesNotMatch(coordinatorPipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) }) diff --git a/scripts/use-nacos-environment.ps1 b/scripts/use-nacos-environment.ps1 index ee5f64734..0e4cdb9d4 100644 --- a/scripts/use-nacos-environment.ps1 +++ b/scripts/use-nacos-environment.ps1 @@ -1,74 +1,17 @@ param( - [ValidateSet('local', 'dev', 'development', 'test')] - [string]$Environment, - [ValidateSet('cn', 'overseas')] - [string]$Region = 'cn', - [string]$NacosServerAddr, - [string]$NacosNamespace, - [string]$NacosUsername, - [string]$NacosPassword + [ValidateSet('test')][string]$Environment = 'test', + [Parameter(Mandatory)][string]$NacosServerAddr, + [Parameter(Mandatory)][string]$NacosNamespace, + [string]$NacosUsername = $env:NACOS_USERNAME, + [string]$NacosPassword = $env:NACOS_PASSWORD ) - $ErrorActionPreference = 'Stop' -$target = if ($Environment -eq 'development') { 'dev' } else { $Environment } - -$defaults = @{ - local = @{ Server = '127.0.0.1:8848'; CnNamespace = 'public' } - dev = @{ Server = '8.163.49.32:8848'; CnNamespace = $env:DEV_NACOS_NAMESPACE } - test = @{ Server = '8.163.30.128:8848'; CnNamespace = 'd99d7998-5bb0-4ffe-a2a0-1a564ce14463' } -} -$selected = $defaults[$target] -$serverAddr = if ($NacosServerAddr) { $NacosServerAddr } else { $selected.Server } -$namespace = if ($NacosNamespace) { - $NacosNamespace -} elseif ($target -eq 'local') { - 'public' -} elseif ($Region -eq 'overseas') { - $regionNamespace = [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_OVERSEAS_NACOS_NAMESPACE") - if ($regionNamespace) { - $regionNamespace - } else { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_NAMESPACE") - } -} else { - $selected.CnNamespace -} -$username = if ($NacosUsername) { - $NacosUsername -} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME")) { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME") -} else { - 'nacos' -} -$password = if ($NacosPassword) { - $NacosPassword -} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD")) { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD") -} elseif ($target -eq 'local') { - 'nacos' -} else { - $null -} - -if (-not $namespace) { - throw "$target/$Region 必须通过 -NacosNamespace 或对应的 *_NACOS_NAMESPACE 环境变量指定 namespace" -} -if (-not $password) { - throw "$target 必须通过 -NacosPassword 或 $($target.ToUpperInvariant())_NACOS_PASSWORD 提供 Nacos 密码" -} - -$env:NACOS_SERVER_ADDR = $serverAddr -$env:NACOS_NAMESPACE = $namespace -$env:NACOS_GROUP = 'BAIYUE' -$env:NACOS_USERNAME = $username -$env:NACOS_PASSWORD = $password -$env:SPRING_CLOUD_NACOS_CONFIG_NAMESPACE = $namespace -$env:SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE = $namespace -$env:SPRING_PROFILES_ACTIVE = if ($Region -eq 'overseas') { 'overseas' } elseif ($target -eq 'local') { 'dev' } else { 'cn' } -$env:BAIYUE_RUNTIME_ENVIRONMENT = $target -$env:BAIYUE_RUNTIME_REGION = $Region - -Write-Host "Runtime environment: $target / $Region" -Write-Host "Nacos: $serverAddr; namespace: $namespace; group: BAIYUE" -Write-Host "Spring profiles: $env:SPRING_PROFILES_ACTIVE" -Write-Host 'Nacos password is loaded into the current process and is not printed.' +if (-not $NacosNamespace -or $NacosNamespace -eq 'public') { throw 'Actual TEST Namespace ID required' } +if (-not $NacosUsername -or -not $NacosPassword) { throw 'Nacos credentials required' } +$env:NACOS_SERVER_ADDR=$NacosServerAddr +$env:NACOS_NAMESPACE=$NacosNamespace +$env:NACOS_GROUP='BAIYUE' +$env:NACOS_USERNAME=$NacosUsername +$env:NACOS_PASSWORD=$NacosPassword +$env:SPRING_PROFILES_ACTIVE='test' +Write-Host 'TEST Nacos configuration loaded into this process; no configuration published.' diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 55adcf0ae..5b5ae5124 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,7 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | -| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与统一 Gateway 的 `overseas-warehouse-resources/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,单机 Compose 使用内部 Print 地址,所有服务位于同一测试 Nacos Namespace;Feign 及熔断包装异常统一返回 `S0503` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 077cf099c..fe31d6f99 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -4,38 +4,38 @@ server: spring: sql: init: - mode: always + mode: never encoding: UTF-8 schema-locations: classpath:db/warehouse.sql data: redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} application: name: warehouse-overseas-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - namespace: ${NACOS_NAMESPACE:public} + server-addr: ${NACOS_SERVER_ADDR} + namespace: ${NACOS_NAMESPACE} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} management.endpoints.web.exposure.include: health,info,prometheus baiyue: @@ -48,10 +48,10 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} internal-service: - token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + token: ${INTERNAL_SERVICE_TOKEN} security: jwt-mode: jwks session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 185d89f5b..98dd5d0fd 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -16,6 +16,7 @@ | FBA 目的仓公共主数据与 Admin 候选 | 权威表 `wh_warehouse` 的 `tenant_id=0 + warehouse_type=FBA` 记录;Admin 直接调用 `GET /api/platform/v1/warehouse-admin?warehouseType=FBA&status=ACTIVE&page=1&size=200`,权限 `platform:warehouse:list`,DTO `WarehouseSummaryResponse` 的 `id/warehouseCode/warehouseName/status/countryCode/primaryAddress.stateProvince` 分别作为 ID/编码/名称/状态/国家/区域 | | FBA 目的仓内部校验 | `InternalWarehouseController.java` 的 `GET /internal/warehouses/0/{warehouseId}/active`,使用内部服务令牌返回 `WarehouseDetailResponse`;调用方仍须校验 `warehouseType=FBA` 及 ID/编码/名称一致,不得把 `OVERSEAS` 实际收货仓或 `DOMESTIC` 中转仓当作 FBA 目的仓 | | 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 | +| 公共海外收货仓平台候选 | `controller/PlatformOverseasReceivingOptionsController.java`;统一 Gateway 的 `/api/platform/v1/warehouse-admin/overseas-receiving-options`,权限 `platform:overseas-warehouse:warehouse:list`,平台主体且公共作用域,固定 `OVERSEAS + ACTIVE + RECEIVING`;分页上限200,契约见 `src/main/resources/openapi/warehouse-v1.yaml` | | 海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 | | 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` | | 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` | diff --git a/warehouse/src/main/resources/application-dev.yml b/warehouse/src/main/resources/application-dev.yml deleted file mode 100644 index 73de396cf..000000000 --- a/warehouse/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/warehouse/src/main/resources/application-test.yml b/warehouse/src/main/resources/application-test.yml deleted file mode 100644 index 5fb37b7f4..000000000 --- a/warehouse/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${WAREHOUSE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${WAREHOUSE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml index 55de853bf..dd13b0081 100644 --- a/warehouse/src/main/resources/application.yml +++ b/warehouse/src/main/resources/application.yml @@ -2,28 +2,27 @@ server: port: ${WAREHOUSE_PORT:9205} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: warehouse-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/warehouse/src/main/resources/openapi/warehouse-v1.yaml b/warehouse/src/main/resources/openapi/warehouse-v1.yaml index 909774b5e..75d263d1b 100644 --- a/warehouse/src/main/resources/openapi/warehouse-v1.yaml +++ b/warehouse/src/main/resources/openapi/warehouse-v1.yaml @@ -1,8 +1,46 @@ openapi: 3.0.3 info: - title: Warehouse internal order catalog + title: Warehouse catalog and receiving options version: 1.0.0 paths: + /api/platform/v1/warehouse-admin/overseas-receiving-options: + get: + summary: 分页查询启用的公共海外收货仓 + description: 仅允许平台主体(appCode=PLATFORM、tenantId=0)且具有 platform:overseas-warehouse:warehouse:list 权限。固定筛选公共 OVERSEAS、ACTIVE、RECEIVING 仓,不授予仓库主档维护权限。统一 Gateway 入口,统一 Access Token。 + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: {type: integer, format: int64, minimum: 1, default: 1} + - name: size + in: query + schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 200} + responses: + '200': + description: 统一响应;code字符串200为成功,业务拒绝返回非200字符串code。Long字段序列化为十进制字符串。 + content: + application/json: + schema: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + nullable: true + properties: + page: {type: string, example: '1'} + size: {type: string, example: '200'} + total: {type: string, example: '2'} + pages: {type: string, example: '1'} + records: + type: array + items: + $ref: '#/components/schemas/ReceivingWarehouseOption' + '401': {description: 无有效统一会话} + '403': {description: 无访问权限} /internal/warehouses/order-catalog: get: summary: 查询订单可引用的启用公共仓库目录 @@ -12,7 +50,22 @@ paths: responses: '200': {description: 返回仓库 ID、编码、名称、类型和国家编码} components: + schemas: + ReceivingWarehouseOption: + type: object + required: [id, customerId, warehouseCode, warehouseName, warehouseType, status] + properties: + id: {type: string, description: 权威仓库ID} + customerId: {type: string, enum: ['0'], description: 平台公共资源作用域} + warehouseCode: {type: string} + warehouseName: {type: string} + warehouseType: {type: string, enum: [OVERSEAS]} + status: {type: string, enum: [ACTIVE]} securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT internalServiceToken: type: apiKey in: header -- 2.54.0 From a530205d2b3ac6ea1d290a8bd3c5c607ea476505 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:47:49 +0800 Subject: [PATCH 012/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/baiyue/common/event/EventTypes.java | 3 --- .../common/event/IamSecurityEventsV1.java | 25 ------------------- .../baiyue/common/event/DeliveryAckTest.java | 2 +- ...tDeliveryObservationAutoConfiguration.java | 2 ++ ...iveryObservationAutoConfigurationTest.java | 18 +++++++++++++ .../LastlegOrderControllerTest.java | 2 +- 6 files changed, 22 insertions(+), 30 deletions(-) delete mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 17172f544..81829864f 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -18,9 +18,6 @@ public final class EventTypes { public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; - public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; - public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; - public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java deleted file mode 100644 index 8d1251801..000000000 --- a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baiyue.common.event; - -import java.time.Instant; -import java.util.Set; - -/** IAM 向海外服务发布的 V1 安全投影事件负载。 */ -public final class IamSecurityEventsV1 { - public static final int VERSION = 1; - - private IamSecurityEventsV1() { } - - public record UserStatusChanged(long authUserId, long tenantId, String appCode, - String status, long permissionVersion, Instant changedAt) { } - - public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { } - - public record PermissionVersionChanged(long authUserId, long tenantId, String appCode, - long permissionVersion, String reason, Instant changedAt, - Set roles, Set authorities) { - public PermissionVersionChanged { - roles = roles == null ? Set.of() : Set.copyOf(roles); - authorities = authorities == null ? Set.of() : Set.copyOf(authorities); - } - } -} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java index 69ac6b3b2..b681a6a47 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -44,6 +44,6 @@ class DeliveryAckTest { assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service"); assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_COMPLETED).serviceName()).isEqualTo("lastleg-service"); assertThatThrownBy(() -> EventDeliveryRoute.forEventType("UnknownEvent")).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> EventDeliveryRoute.forEventType(EventTypes.IAM_USER_STATUS_CHANGED)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType("IamUserStatusChanged")).isInstanceOf(IllegalArgumentException.class); } } diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java index efa4c05f8..473a4a3a4 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.outbox.EventDeliveryProducer; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; import org.springframework.context.annotation.Bean; @@ -13,6 +14,7 @@ import org.springframework.transaction.PlatformTransactionManager; @AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}) @ConditionalOnBean(EventDeliveryProducer.class) +@ConditionalOnProperty(prefix = "baiyue.event-delivery", name = "observation-enabled", havingValue = "true") public class EventDeliveryObservationAutoConfiguration { @Bean EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java index 19a1a9674..b772fb46e 100644 --- a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java @@ -11,12 +11,14 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.beans.factory.annotation.Autowired; import static org.assertj.core.api.Assertions.*; class EventDeliveryObservationAutoConfigurationTest { @Test void configuresMandatoryAuditAndMetricsForServiceLocalJdbc() { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:auto", "sa", "")); new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) .withBean(JdbcTemplate.class, () -> jdbc) .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) @@ -28,6 +30,7 @@ class EventDeliveryObservationAutoConfigurationTest { } @Test void otherJdbcServicesDoNotAcquireTransactionOrMetricsRequirements() { new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:other", "sa", ""))) .run(context -> { assertThat(context).hasNotFailed(); @@ -38,13 +41,28 @@ class EventDeliveryObservationAutoConfigurationTest { @Test void producerWithoutMeterRegistryFailsInsteadOfSilentlyDisablingObservation() { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noRegistry", "sa", "")); new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) .withBean(JdbcTemplate.class, () -> jdbc) .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) .run(context -> assertThat(context).hasFailed()); } + static class RequiredProducer implements EventDeliveryProducer { + @Autowired EventDeliveryJournal journal; + @Autowired EventDeliveryMetrics metrics; + } + @Test void producerMissingTheExplicitObservationSwitchCannotStart() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noSwitch", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(RequiredProducer.class, RequiredProducer::new) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> assertThat(context).hasFailed()); + } @Test void missingTransactionManagerFailsRatherThanInstallingNonTransactionalFallback() { new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:missing", "sa", ""))) .withBean(MeterRegistry.class, SimpleMeterRegistry::new) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index d9d71ed41..8e378e736 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -152,7 +152,7 @@ class LastlegOrderControllerTest { @Test void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { EventEnvelope event = mock(EventEnvelope.class); - when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); + when(event.eventType()).thenReturn("IamUserStatusChanged"); LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); assertThrows(com.baiyue.common.event.EventProtocolException.class, () -> controller.receive("internal-token", event)); -- 2.54.0 From 7bc4c52ba4bb390e85249487cb12b8a452d8fc28 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:48:57 +0800 Subject: [PATCH 013/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ntDeliveryObservationAutoConfiguration.java | 2 -- ...liveryObservationAutoConfigurationTest.java | 18 ------------------ config/nacos/test/finance-service.yml | 1 - config/nacos/test/firstleg-service.yml | 1 - config/nacos/test/lastleg-service.yml | 1 - 5 files changed, 23 deletions(-) diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java index 473a4a3a4..efa4c05f8 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -5,7 +5,6 @@ import com.baiyue.common.core.outbox.EventDeliveryProducer; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; import org.springframework.context.annotation.Bean; @@ -14,7 +13,6 @@ import org.springframework.transaction.PlatformTransactionManager; @AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}) @ConditionalOnBean(EventDeliveryProducer.class) -@ConditionalOnProperty(prefix = "baiyue.event-delivery", name = "observation-enabled", havingValue = "true") public class EventDeliveryObservationAutoConfiguration { @Bean EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java index b772fb46e..19a1a9674 100644 --- a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java @@ -11,14 +11,12 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.beans.factory.annotation.Autowired; import static org.assertj.core.api.Assertions.*; class EventDeliveryObservationAutoConfigurationTest { @Test void configuresMandatoryAuditAndMetricsForServiceLocalJdbc() { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:auto", "sa", "")); new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) - .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) .withBean(JdbcTemplate.class, () -> jdbc) .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) @@ -30,7 +28,6 @@ class EventDeliveryObservationAutoConfigurationTest { } @Test void otherJdbcServicesDoNotAcquireTransactionOrMetricsRequirements() { new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) - .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:other", "sa", ""))) .run(context -> { assertThat(context).hasNotFailed(); @@ -41,28 +38,13 @@ class EventDeliveryObservationAutoConfigurationTest { @Test void producerWithoutMeterRegistryFailsInsteadOfSilentlyDisablingObservation() { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noRegistry", "sa", "")); new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) - .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) .withBean(JdbcTemplate.class, () -> jdbc) .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) .run(context -> assertThat(context).hasFailed()); } - static class RequiredProducer implements EventDeliveryProducer { - @Autowired EventDeliveryJournal journal; - @Autowired EventDeliveryMetrics metrics; - } - @Test void producerMissingTheExplicitObservationSwitchCannotStart() { - var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noSwitch", "sa", "")); - new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) - .withBean(RequiredProducer.class, RequiredProducer::new) - .withBean(JdbcTemplate.class, () -> jdbc) - .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) - .withBean(MeterRegistry.class, SimpleMeterRegistry::new) - .run(context -> assertThat(context).hasFailed()); - } @Test void missingTransactionManagerFailsRatherThanInstallingNonTransactionalFallback() { new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) - .withPropertyValues("baiyue.event-delivery.observation-enabled=true") .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:missing", "sa", ""))) .withBean(MeterRegistry.class, SimpleMeterRegistry::new) diff --git a/config/nacos/test/finance-service.yml b/config/nacos/test/finance-service.yml index bbc4da782..dca977209 100644 --- a/config/nacos/test/finance-service.yml +++ b/config/nacos/test/finance-service.yml @@ -7,7 +7,6 @@ spring: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} -baiyue.event-delivery.observation-enabled: true baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} diff --git a/config/nacos/test/firstleg-service.yml b/config/nacos/test/firstleg-service.yml index ca18c236b..c677a2da4 100644 --- a/config/nacos/test/firstleg-service.yml +++ b/config/nacos/test/firstleg-service.yml @@ -78,7 +78,6 @@ firstleg: subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} warehouse-bindings: CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} -baiyue.event-delivery.observation-enabled: true baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} diff --git a/config/nacos/test/lastleg-service.yml b/config/nacos/test/lastleg-service.yml index b555e61c4..f5da05865 100644 --- a/config/nacos/test/lastleg-service.yml +++ b/config/nacos/test/lastleg-service.yml @@ -50,7 +50,6 @@ baiyue: firstleg: outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} -baiyue.event-delivery.observation-enabled: true baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} -- 2.54.0 From 64a70c9f28b28ed60fc7e343acf3577f3679fcf6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:48:58 +0800 Subject: [PATCH 014/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-event-contract/AGENTS.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 6a8721622..ab5e442cc 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -2,8 +2,8 @@ ## 职责边界 -- 维护国内头程与国外尾程之间稳定、可版本化的事件契约。 -- 只放事件信封、类型、区域、标识、规范化、哈希和校验;不放任一侧领域 Entity、数据库模型或发送实现。 +- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。 +- 只放事件信封、类型、标识、规范化、哈希和校验;不放部署区域、领域 Entity、数据库模型或发送实现。 ## 快速索引 @@ -13,7 +13,7 @@ | 契约校验 | `EventEnvelopeValidator.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | -| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | +| 事件类型与直接目标 | `EventTypes.java`、`EventDeliveryRoute.java`;明确白名单,不按区域或默认目标分派 | | 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | @@ -22,7 +22,6 @@ | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | -| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | @@ -31,6 +30,6 @@ - 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 -- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 -- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 +- 业务事件不携带用户 Access Token;直接投递使用内部服务身份,信封保留事件ID、业务类型/版本、客户、聚合、追踪及Payload哈希,不再包含sourceRegion/targetRegion。 +- `OVERSEAS_CONTAINER_UNLOADED` 固定由Lastleg Outbox直投Firstleg,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间规则沿用现业务实现,实际报空时间允许后续补充。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 -- 2.54.0 From 6c0a026cc489cf2568d7f1ea9aad0cd17ca513bb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 19:48:34 +0800 Subject: [PATCH 015/176] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=8D=95=E6=9C=BATES?= =?UTF-8?q?T=E7=BB=9F=E4=B8=80=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=8F=AF?= =?UTF-8?q?=E9=9D=A0=E6=8A=95=E9=80=92=E5=9B=9E=E5=BD=92=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...izationFeignConfigurationContractTest.java | 96 +++++++++++ ...AuthTokenTtlConfigurationContractTest.java | 6 +- common-core/AGENTS.md | 1 + common-core/pom.xml | 2 +- .../core/outbox/EventDeliveryJournal.java | 2 +- common-web/AGENTS.md | 1 + common-web/pom.xml | 1 + .../common/web/EventDeliveryFailure.java | 3 +- .../common/web/EventDeliveryObservation.java | 1 + ...tDeliveryObservationAutoConfiguration.java | 2 + .../web/EventDeliveryObservationTest.java | 9 ++ config/nacos/test/auth-service.yml | 8 + .../config/CustomsFileExportContractTest.java | 7 +- ...esticWarehouseOrderExportContractTest.java | 5 +- .../ExportInfrastructureContractTest.java | 14 +- .../finance/FinanceExportContractTest.java | 11 +- firstleg/pom.xml | 1 + ...entBridgeClientDeploymentContractTest.java | 21 +-- ...omsMultipartConfigurationContractTest.java | 3 +- .../repository/OutboxLeaseDatabaseTest.java | 2 +- ...merTransitStatisticsRouteContractTest.java | 11 ++ .../DeploymentHeaderSizeContractTest.java | 5 +- .../config/GatewayCorsConfigurationTest.java | 11 ++ .../GatewayFileRouteConfigurationTest.java | 11 ++ ...fflineGatewayConfigurationInitializer.java | 43 +++++ .../resources/gateway-offline-test.properties | 2 + ...NotificationConfigurationContractTest.java | 6 +- ...imensionInitializationSqlContractTest.java | 12 +- .../Phase4BrowserApiBoundaryTest.java | 13 +- ...gPhotoUploadConfigurationContractTest.java | 4 +- ...InboundUnloadCompletedOutboxRelayTest.java | 1 + ...ticPartnerWarehouseClientContractTest.java | 31 ++-- .../warehouse/PdaWarehouseContractTest.java | 15 +- .../repository/OutboxLeaseDatabaseTest.java | 4 +- .../Phase5OverseasUpgradeSqlContractTest.java | 14 +- .../SchemaChineseCommentContractTest.java | 4 +- .../service/LoadingCompletedConsumerTest.java | 2 +- .../print/PrintServiceApplicationTest.java | 6 + scripts/AGENTS.md | 4 +- scripts/README.md | 23 +-- scripts/admin-test-env-acceptance.mjs | 143 +++------------- scripts/admin-test-env-modules/README.md | 8 +- .../customer-system-contract.test.mjs | 6 +- .../dropship-outbound-edge-cases.mjs | 2 +- .../dropship-pallet-matrix-contract.test.mjs | 2 +- .../dropship-pallet-matrix.mjs | 15 +- .../event-bridge-pending-recovery.mjs | 73 ++++----- .../finance-direct-paid-isolation.mjs | 14 +- ...merged-overseas-coverage-contract.test.mjs | 4 +- ...-phase4-producer-fixture-contract.test.mjs | 2 +- .../phase4-container-inbound-plan-batch.mjs | 15 +- ...ase4-cross-region-events-contract.test.mjs | 37 +++-- .../phase4-cross-region-events.mjs | 47 +++--- ...phase4-overseas-business-contract.test.mjs | 30 ++-- .../phase4-overseas-business.mjs | 63 +++----- ...overseas-client-services-contract.test.mjs | 4 +- .../phase4-overseas-client-services.mjs | 21 ++- .../phase4-overseas-dispatch.mjs | 8 +- ...eas-finance-cross-region-contract.test.mjs | 57 +++---- .../phase4-overseas-finance-cross-region.mjs | 25 ++- .../phase4-overseas-inbound-operations.mjs | 19 +-- ...se4-overseas-master-data-contract.test.mjs | 96 +++++------ .../phase4-overseas-master-data.mjs | 71 ++++---- .../phase4-overseas-pda-contract.test.mjs | 48 +++--- ...-overseas-pda-operations-contract.test.mjs | 2 +- .../phase4-overseas-pda-operations.mjs | 38 ++--- .../phase4-overseas-pda.mjs | 90 ++++------- .../phase4-overseas-return-followups.mjs | 26 +-- ...-overseas-return-receipt-contract.test.mjs | 16 +- .../phase4-overseas-return-receipt.mjs | 56 +++---- ...phase4-overseas-wallet-and-pallet-cost.mjs | 61 ++++--- ...hase4-overseas-warehouse-contract.test.mjs | 60 +++---- .../phase4-overseas-warehouse.mjs | 35 ++-- ...ase4-tail-order-controls-contract.test.mjs | 2 +- .../phase4-tail-order-controls.mjs | 29 ++-- ...se5-overseas-fulfillment-contract.test.mjs | 2 +- .../phase5-overseas-fulfillment.mjs | 28 ++-- ...eturn-boundary-scenarios-contract.test.mjs | 2 +- .../return-boundary-scenarios.mjs | 19 ++- .../return-review-contract.test.mjs | 2 +- scripts/admin-test-env-modules/system.mjs | 7 +- ...nt-bridge-dns-deployment-contract.test.mjs | 153 ++++++------------ ...illeg-owner-order-number-contract.test.mjs | 11 +- .../warehouse-transfer-review.mjs | 2 +- scripts/backend-full-regression.ps1 | 4 +- scripts/client-test-env-acceptance.mjs | 1 + .../full-regression-entry-contract.test.mjs | 9 +- ...verseas-acceptance-entry-contract.test.mjs | 52 +++--- ...rehouseDatabaseGuardConfigurationTest.java | 16 +- 89 files changed, 968 insertions(+), 983 deletions(-) create mode 100644 auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java create mode 100644 gateway/src/test/resources/gateway-offline-test.properties diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java new file mode 100644 index 000000000..fe67e14bb --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java @@ -0,0 +1,96 @@ +package com.baiyue.auth.client; + +import feign.Client; +import feign.FeignException; +import feign.Request; +import feign.Response; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.FeignClientProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.FileSystemResource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.*; + +class AuthorizationFeignConfigurationContractTest { + @Test + void actualNamedNacosConfigMatchesContextIdAndOverridesGlobalFeignBudget() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignAutoConfiguration.class, + JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class)) + .withUserConfiguration(OfflineFeignConfiguration.class) + .withPropertyValues("baiyue.clients.iam.url=http://127.0.0.1:1") + .withInitializer(context -> { + for (String file : new String[]{"baiyue-common.yml", "auth-service.yml"}) { + Path path = Path.of("config", "nacos", "test", file); + if (!Files.isRegularFile(path)) path = Path.of("..").resolve(path); + try { + // Load real configuration as properties only, without config-data imports. + for (var source : new YamlPropertySourceLoader().load(file, new FileSystemResource(path))) { + context.getEnvironment().getPropertySources().addLast(source); + } + } catch (java.io.IOException failure) { + throw new IllegalStateException("Cannot read actual Auth Feign configuration", failure); + } + } + }) + .run(context -> { + assertThat(context).hasNotFailed(); + String contextId = IamAuthorizationClient.class.getAnnotation(FeignClient.class).contextId(); + var properties = context.getBean(FeignClientProperties.class); + var named = properties.getConfig().get(contextId); + assertThat(named).as("Nacos key must match actual @FeignClient contextId").isNotNull(); + assertThat(named.getConnectTimeout()).isEqualTo(1000); + assertThat(named.getReadTimeout()).isEqualTo(2000); + assertThat(named.isFollowRedirects()).isFalse(); + assertThat(properties.getConfig().get("default").getConnectTimeout()).isEqualTo(3000); + assertThat(properties.getConfig().get("default").getReadTimeout()).isEqualTo(5000); + + var transport = context.getBean(CapturingClient.class); + var client = context.getBean(IamAuthorizationClient.class); + var request = new AuthorizationSnapshotRequest(1L, 7L, 9L, "MERCHANT"); + client.load("offline-service-token", request); + assertThat(transport.options.get().connectTimeoutMillis()).isEqualTo(1000); + assertThat(transport.options.get().readTimeoutMillis()).isEqualTo(2000); + assertThat(transport.options.get().isFollowRedirects()).isFalse(); + + transport.status.set(503); + assertThatThrownBy(() -> client.load("offline-service-token", request)).isInstanceOf(FeignException.class); + assertThat(transport.calls.get()).as("One success plus one failure, without automatic retry").isEqualTo(2); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableFeignClients(clients = IamAuthorizationClient.class) + static class OfflineFeignConfiguration { + @Bean CapturingClient capturingClient() { return new CapturingClient(); } + } + + /** The real Feign factory builds the client; only network I/O is replaced. */ + static class CapturingClient implements Client { + final AtomicReference options = new AtomicReference<>(); + final AtomicInteger calls = new AtomicInteger(); + final AtomicInteger status = new AtomicInteger(204); + + @Override public Response execute(Request request, Request.Options effectiveOptions) { + options.set(effectiveOptions); + calls.incrementAndGet(); + return Response.builder().request(request).status(status.get()).reason("offline") + .headers(Map.of()).build(); + } + } +} diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java index 0b7440f31..d597d7097 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -10,14 +10,14 @@ import static org.assertj.core.api.Assertions.assertThat; class AuthTokenTtlConfigurationContractTest { @Test - void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception { + void testNacosConfigurationKeepsUnifiedShortAccessAndSevenDayRefreshTokens() throws Exception { String configuration = Files.readString( - Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8); + Path.of("..", "config", "nacos", "test", "auth-service.yml"), StandardCharsets.UTF_8); assertThat(configuration) .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}") .contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}") - .contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}") + .doesNotContain("OVERSEAS_ACCESS_TOKEN_TTL") .doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}"); } } diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 564b68cfd..e725c4e21 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -16,6 +16,7 @@ | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | | 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 | | 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 | +| 逐次投递审计事务 | `src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java`;领取与STARTED、持有者CAS完成与审计各自同库REQUIRES_NEW事务;HTTP在两个事务之间执行,迟到结果不得写入;JDBC为optional依赖,Print等无数据库服务不被引入JDBC | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | ## 修改与验证 diff --git a/common-core/pom.xml b/common-core/pom.xml index 9aef71cb6..956d0ca79 100644 --- a/common-core/pom.xml +++ b/common-core/pom.xml @@ -24,7 +24,7 @@ spring-boot-starter-test test - org.springframeworkspring-jdbc + org.springframeworkspring-jdbctrue com.h2databaseh2test diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java index c5b00f76b..4783d1b52 100644 --- a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java @@ -40,7 +40,7 @@ public final class EventDeliveryJournal { public boolean complete(String attempt, Outcome outcome, LocalDateTime now, String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) { - if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode) + if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "INVALID_RESPONSE", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode) && !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code"); return Boolean.TRUE.equals(transaction.execute(status -> { int changed = updateOwnedOutbox.getAsInt(); diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 6e566731f..edc326106 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -14,6 +14,7 @@ | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | | 可靠事件 HTTP 客户端配置 | `EventDeliveryFeignConfiguration.java`;由业务事件 Feign 客户端显式选择,在最终构建阶段固定连接/读取超时、禁止重定向和客户端内部重试,不作为全局自动配置 | +| 投递审计和指标装配 | `EventDeliveryObservationAutoConfiguration.java`、`EventDeliveryMetrics.java`、`EventDeliveryObservation.java`;仅EventDeliveryProducer标记的业务Relay装配必需Journal/Metrics,其他JDBC服务不装配;定时采样固定事件白名单,Prometheus仅读内存快照,采样失败保留旧值并暴露失败/时间,错误标签不含载荷或令牌 | | RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | diff --git a/common-web/pom.xml b/common-web/pom.xml index 47be8e5ac..084dd86b8 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -14,6 +14,7 @@ baiyue-common-web + org.springframeworkspring-jdbctrue com.baiyue.sharedcommon-event-contract${project.version} com.h2databaseh2test diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java index 341bd0e36..3210dbd2e 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java @@ -42,7 +42,8 @@ public class EventDeliveryFailure extends IllegalStateException { && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) || "No fallback available.".equals(current.getMessage()))) current = current.getCause(); String message; - if (current instanceof FeignException http) message = "HTTP " + http.status(); + if (current instanceof feign.codec.DecodeException) message = "Invalid consumer response contract"; + else if (current instanceof FeignException http) message = "HTTP " + http.status(); else if (current instanceof JsonProcessingException) message = "Invalid event JSON"; else message = current.getMessage() == null ? "unknown" : current.getMessage(); String text = current.getClass().getSimpleName() + ": " + message; diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java index dc184aef3..8d38e26b0 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java @@ -19,6 +19,7 @@ public final class EventDeliveryObservation { public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) { Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) { + if (cause instanceof feign.codec.DecodeException) return "INVALID_RESPONSE"; if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) { return "HTTP_" + http.status(); } diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java index efa4c05f8..35c6225f8 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.outbox.EventDeliveryProducer; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; import org.springframework.context.annotation.Bean; @@ -13,6 +14,7 @@ import org.springframework.transaction.PlatformTransactionManager; @AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}) @ConditionalOnBean(EventDeliveryProducer.class) +@ConditionalOnClass(name = "org.springframework.jdbc.core.JdbcTemplate") public class EventDeliveryObservationAutoConfiguration { @Bean EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java index 83f6a9ef8..036c54ca4 100644 --- a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java @@ -5,6 +5,15 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; class EventDeliveryObservationTest { + @Test void malformedSuccessResponseIsNotReportedAsAnHttpSuccess() { + var request = feign.Request.create(feign.Request.HttpMethod.POST, "http://consumer.invalid/events", + java.util.Map.of(), new byte[0], java.nio.charset.StandardCharsets.UTF_8, new feign.RequestTemplate()); + var failure = new feign.codec.DecodeException(200, "secret remote payload", request); + assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("INVALID_RESPONSE"); + assertThat(EventDeliveryFailure.diagnostic(failure, "token")) + .contains("Invalid consumer response contract").doesNotContain("secret", "HTTP 200"); + assertThat(EventDeliveryFailure.isPermanent(failure)).isFalse(); + } @Test void auditIdentitySupportsTheFormalUuidAndUlidContractWithoutSavingMalformedPayload() { var mapper = new ObjectMapper(); for (String id : new String[]{"00000000-0000-4000-8000-000000000019", "01ARZ3NDEKTSV4RRFFQ69G5FAV"}) { diff --git a/config/nacos/test/auth-service.yml b/config/nacos/test/auth-service.yml index 10ae3fae1..9e4613181 100644 --- a/config/nacos/test/auth-service.yml +++ b/config/nacos/test/auth-service.yml @@ -1,4 +1,12 @@ spring: + cloud: + openfeign: + client: + config: + iamAuthorizationClient: + connect-timeout: 1000 + read-timeout: 2000 + follow-redirects: false datasource: url: ${AUTH_DB_URL} username: ${AUTH_DB_USERNAME} diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java index 29fef5e9e..72945f2c6 100644 --- a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -11,10 +11,9 @@ class CustomsFileExportContractTest { private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS"; @Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception { - for (String file : new String[]{ - "file/src/main/resources/application.yml", - "config/nacos/local/file-service.yml", - "config/nacos/cn/file-service.yml"}) { + assertThat(Files.readString(repositoryRoot().resolve("file/src/main/resources/application.yml"))) + .contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:"); + for (String file : new String[]{"config/nacos/test/file-service.yml"}) { String source = Files.readString(repositoryRoot().resolve(file)); assertThat(source).contains(TYPE + ":") .contains("provider-service: firstleg-service") diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java index 97bcb5f18..f676b361d 100644 --- a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -14,8 +14,9 @@ class DomesticWarehouseOrderExportContractTest { private static final String TYPE = "DOMESTIC_WAREHOUSE_ORDER_LIST"; @Test void applicationAndNacosRegisterTheSameOrderProvider() throws Exception { - for (String file : List.of("file/src/main/resources/application.yml", - "config/nacos/local/file-service.yml", "config/nacos/cn/file-service.yml")) { + assertThat(Files.readString(repositoryRoot().resolve("file/src/main/resources/application.yml"))) + .contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:"); + for (String file : List.of("config/nacos/test/file-service.yml")) { String source = Files.readString(repositoryRoot().resolve(file)); assertThat(source).contains(TYPE + ":") .contains("provider-service: order-service") diff --git a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java index 41bcb25fa..4cd57f188 100644 --- a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java @@ -16,12 +16,14 @@ class ExportInfrastructureContractTest { assertTrue(compose.contains("FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}")); assertTrue(compose.contains("FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}")); assertTrue(compose.contains("-Xms1g -Xmx2g")); - assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}")); - assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-}")); - String deployScript=read("..","deploy","scripts","deploy-services.sh"); - assertTrue(deployScript.contains("部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX")); - assertTrue(deployScript.contains("测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-")); - assertTrue(deployScript.contains("环境文件必须包含非空 ROCKETMQ_NAME_SERVER")); + assertTrue(compose.contains("MQ_TOPIC_PREFIX: test-")); + assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER}")); + String entry=read("..","deploy","scripts","deploy-services.sh"); + assertTrue(entry.contains("sh deploy/scripts/deploy-built-service.sh")); + String deployScript=read("..","deploy","scripts","deploy-built-service.sh"); + assertTrue(deployScript.contains("MQ_TOPIC_PREFIX must be test-")); + assertTrue(deployScript.contains("ROCKETMQ_NAME_SERVER")); + assertTrue(deployScript.contains("env_file_value")); } @Test void rocketMqBootstrapCreatesBothGroupsAndAReadableWorkerDlq() throws Exception { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java index eed2eb18e..a56e35abc 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -22,18 +22,15 @@ class FinanceExportContractTest { void keepsAllFileDefinitionsOnTheSameNineTypes() throws Exception { Path backendRoot = backendRoot(); String application = Files.readString(backendRoot.resolve("file/src/main/resources/application.yml"), StandardCharsets.UTF_8); - String local = Files.readString(backendRoot.resolve("config/nacos/local/file-service.yml"), StandardCharsets.UTF_8); - String cn = Files.readString(backendRoot.resolve("config/nacos/cn/file-service.yml"), StandardCharsets.UTF_8); + String test = Files.readString(backendRoot.resolve("config/nacos/test/file-service.yml"), StandardCharsets.UTF_8); Yaml yaml = new Yaml(); assertThat((Object) yaml.load(application)).isNotNull(); - assertThat((Object) yaml.load(local)).isNotNull(); - assertThat((Object) yaml.load(cn)).isNotNull(); + assertThat((Object) yaml.load(test)).isNotNull(); + assertThat(application).contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:"); for (String type : TYPES) { - assertThat(application).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); - assertThat(local).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); - assertThat(cn).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + assertThat(test).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); } } diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 98dc43916..a9e9e245a 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml firstleg-service + com.h2databaseh2test org.springframework.bootspring-boot-starter-mail com.baiyue.sharedbaiyue-common-core${project.version} com.baiyue.sharedcommon-event-contract${project.version} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java index b22423e5f..e3e80589e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java @@ -12,21 +12,22 @@ class EventBridgeClientDeploymentContractTest { private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); @Test - void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception { + void firstlegDirectTargetUsesServiceDiscoveryOrExplicitServiceUrl() throws Exception { String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml")); - String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml")); - - assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}")); - assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1")); - assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/test/firstleg-service.yml")); + var feign = LastlegEventClient.class.getAnnotation(org.springframework.cloud.openfeign.FeignClient.class); + assertTrue(feign.name().equals("lastleg-service")); + assertTrue(feign.url().equals("${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")); + assertFalse((application + nacos).contains("EVENT_BRIDGE_URL")); + assertFalse(feign.url().contains("127.0.0.1")); } @Test - void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception { + void testComposeUsesDirectBusinessServiceDns() throws Exception { String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); - assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}")); - assertTrue(compose.contains("SERVER_PORT: 9312")); - assertTrue(compose.contains("aliases: [event-bridge-cn]")); + assertTrue(compose.contains("LASTLEG_SERVICE_URL: http://lastleg-service:9303")); + assertTrue(compose.contains("firstleg-test-network")); + assertFalse(compose.contains("event-bridge")); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java index ccbc57e4b..7a5a60e89 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java @@ -14,8 +14,7 @@ class ContainerCustomsMultipartConfigurationContractTest { @Test void applicationAndEnvironmentConfigsPermitFiftyMegabyteCustomsFiles() throws Exception { assertLimits(read("firstleg/src/main/resources/application.yml")); - assertLimits(read("config/nacos/cn/firstleg-service.yml")); - assertLimits(read("config/nacos/local/firstleg-service.yml")); + assertLimits(read("config/nacos/test/firstleg-service.yml")); } @Test void customsOpenApiPublishesFileAndMultipartRequestLimits() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java index 63cf7e5df..edfd11d77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java @@ -29,7 +29,7 @@ class OutboxLeaseDatabaseTest { var repository = new LoadingCompletedOutboxRepository(); ReflectionTestUtils.setField(repository, "mapper", mapper); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); jdbc.update("INSERT INTO fl_inbound_outbox(id,event_type,publish_status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "FirstLegLoadingPlanCompleted", now.minusSeconds(1), now.minusMinutes(3)); assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); diff --git a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java index bd212811f..bab8c2557 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java @@ -3,11 +3,22 @@ package com.baiyue.gateway.config; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; import org.springframework.cloud.gateway.config.GatewayProperties; import static org.assertj.core.api.Assertions.assertThat; +@ContextConfiguration(initializers = OfflineGatewayConfigurationInitializer.class) @SpringBootTest(properties = { + "spring.config.location=classpath:/gateway-offline-test.properties", + "spring.data.redis.host=127.0.0.1", + "spring.data.redis.port=1", + "spring.cloud.nacos.server-addr=127.0.0.1:1", + "spring.cloud.nacos.username=offline-test", + "spring.cloud.nacos.password=offline-test", + "spring.cloud.nacos.discovery.namespace=offline-test", + "spring.cloud.nacos.config.namespace=offline-test", + "baiyue.security.internal-service-token=offline-test-service-token-32-bytes", "spring.cloud.nacos.discovery.enabled=false", "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", diff --git a/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java index 38549c6f4..a3206b626 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java @@ -15,10 +15,9 @@ class DeploymentHeaderSizeContractTest { "SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}"; @Test - void allowsAuthorizationTokensAndTrustedIdentityHeadersAcrossAllServices() throws Exception { + void allowsUnifiedAuthorizationHeadersAcrossAllServices() throws Exception { assertThat(readRepositoryFile("deploy", "docker-compose.yml")).contains(ENVIRONMENT); - assertThat(readRepositoryFile("config", "nacos", "cn", "baiyue-common.yml")).contains(PROPERTY); - assertThat(readRepositoryFile("config", "nacos", "local", "baiyue-common.yml")).contains(PROPERTY); + assertThat(readRepositoryFile("config", "nacos", "test", "baiyue-common.yml")).contains(PROPERTY); } private static String readRepositoryFile(String first, String... more) throws Exception { diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index 09755984a..b5cb63453 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.test.web.reactive.server.WebTestClient; @@ -13,7 +14,17 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertThrows; +@ContextConfiguration(initializers = OfflineGatewayConfigurationInitializer.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.config.location=classpath:/gateway-offline-test.properties", + "spring.data.redis.host=127.0.0.1", + "spring.data.redis.port=1", + "spring.cloud.nacos.server-addr=127.0.0.1:1", + "spring.cloud.nacos.username=offline-test", + "spring.cloud.nacos.password=offline-test", + "spring.cloud.nacos.discovery.namespace=offline-test", + "spring.cloud.nacos.config.namespace=offline-test", + "baiyue.security.internal-service-token=offline-test-service-token-32-bytes", "spring.cloud.nacos.discovery.enabled=false", "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index e792921e3..e5f7900c9 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -3,13 +3,24 @@ package com.baiyue.gateway.config; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.http.server.PathContainer; import org.springframework.web.util.pattern.PathPatternParser; import static org.assertj.core.api.Assertions.assertThat; +@ContextConfiguration(initializers = OfflineGatewayConfigurationInitializer.class) @SpringBootTest(properties = { + "spring.config.location=classpath:/gateway-offline-test.properties", + "spring.data.redis.host=127.0.0.1", + "spring.data.redis.port=1", + "spring.cloud.nacos.server-addr=127.0.0.1:1", + "spring.cloud.nacos.username=offline-test", + "spring.cloud.nacos.password=offline-test", + "spring.cloud.nacos.discovery.namespace=offline-test", + "spring.cloud.nacos.config.namespace=offline-test", + "baiyue.security.internal-service-token=offline-test-service-token-32-bytes", "spring.cloud.nacos.discovery.enabled=false", "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", diff --git a/gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java b/gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java new file mode 100644 index 000000000..2529d85dd --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java @@ -0,0 +1,43 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.io.FileSystemResource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; + +/** Uses actual production routes without invoking production config-data imports. */ +public final class OfflineGatewayConfigurationInitializer + implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext context) { + Path production = Path.of("src", "main", "resources", "application.yml"); + if (!Files.isRegularFile(production)) { + production = Path.of("gateway", "src", "main", "resources", "application.yml"); + } + try { + var sources = new YamlPropertySourceLoader().load("gateway-production", + new FileSystemResource(production)); + // Later YAML documents take precedence; test properties remain above all of them. + for (int i = sources.size() - 1; i >= 0; i--) { + var source = (EnumerablePropertySource) sources.get(i); + var properties = new LinkedHashMap(); + for (String key : source.getPropertyNames()) { + if (!key.equals("spring.config.import") && !key.startsWith("spring.config.import[")) { + properties.put(key, source.getProperty(key)); + } + } + context.getEnvironment().getPropertySources().addLast( + new MapPropertySource("offline-" + source.getName(), properties)); + } + } catch (IOException failure) { + throw new IllegalStateException("Cannot load production Gateway configuration for offline tests", failure); + } + } +} diff --git a/gateway/src/test/resources/gateway-offline-test.properties b/gateway/src/test/resources/gateway-offline-test.properties new file mode 100644 index 000000000..5b0687cbb --- /dev/null +++ b/gateway/src/test/resources/gateway-offline-test.properties @@ -0,0 +1,2 @@ +# Offline bootstrap only. OfflineGatewayConfigurationInitializer loads the production +# application.yml after config-data processing, excluding only Nacos import keys. diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java index a21f4e66b..197357173 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java @@ -12,10 +12,8 @@ class NotificationConfigurationContractTest { @Test void applicationAndNacosKeepConsumersDisabledAndExposeRecoveryTimeout() throws Exception { assertSafeDefaults(read(Path.of("iam", "src", "main", "resources", "application.yml"), Path.of("src", "main", "resources", "application.yml"))); - assertSafeDefaults(read(Path.of("config", "nacos", "local", "iam-service.yml"), - Path.of("..", "config", "nacos", "local", "iam-service.yml"))); - assertSafeDefaults(read(Path.of("config", "nacos", "cn", "iam-service.yml"), - Path.of("..", "config", "nacos", "cn", "iam-service.yml"))); + assertSafeDefaults(read(Path.of("config", "nacos", "test", "iam-service.yml"), + Path.of("..", "config", "nacos", "test", "iam-service.yml"))); } private void assertSafeDefaults(String yaml) { diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java index 0ddd9a741..048bebeaa 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -34,15 +34,13 @@ class CustomerDimensionInitializationSqlContractTest { } @Test - void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception { + void schedulerRetryBudgetHasSafeOverridableDefaultsInTestConfiguration() throws Exception { String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8); assertSchedulerConfig(application); - for (String profile : new String[] {"local", "cn"}) { - Path direct = Path.of("config", "nacos", profile, "iam-service.yml"); - Path config = Files.exists(direct) ? direct - : Path.of("..", "config", "nacos", profile, "iam-service.yml"); - assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8)); - } + Path direct = Path.of("config", "nacos", "test", "iam-service.yml"); + Path config = Files.exists(direct) ? direct + : Path.of("..", "config", "nacos", "test", "iam-service.yml"); + assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8)); } private static void assertContract(String sql) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 890f61a38..85e786ae1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -73,11 +73,18 @@ class Phase4BrowserApiBoundaryTest { } @Test - void overseasLastlegReachesDomesticPrintThroughSharedInfrastructureNetwork() throws Exception { - Path composePath = Path.of("deploy/docker-compose.overseas.yml"); + void lastlegReachesPrintThroughTheUnifiedTestNetwork() throws Exception { + Path composePath = Path.of("deploy/docker-compose.yml"); if (!Files.exists(composePath)) composePath = Path.of("..").resolve(composePath); String compose = Files.readString(composePath); - assertTrue(compose.contains("PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}")); + String lastleg = compose.substring(compose.indexOf(" lastleg-service:"), compose.indexOf(" warehouse-overseas-service:")); + assertTrue(lastleg.contains("<<: *common")); + assertTrue(lastleg.contains("<<: *common-environment")); + assertTrue(lastleg.contains("PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}")); + assertTrue(compose.contains("networks: [firstleg-network, infra-network]")); + assertTrue(compose.contains("name: firstleg-test-network")); + assertFalse(lastleg.contains("ports:")); + assertFalse(compose.contains("gateway-overseas")); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java index 137aaf146..253251f31 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java @@ -12,9 +12,9 @@ class ReceivingPhotoUploadConfigurationContractTest { @Test void lastlegAcceptsTheSameTenMegabyteLimitAsFileService() throws Exception { String application = read("lastleg-service/src/main/resources/application.yml"); - String overseas = read("config/nacos/overseas/lastleg-service.yml"); + String testConfiguration = read("config/nacos/test/lastleg-service.yml"); - for (String configuration : new String[]{application, overseas}) { + for (String configuration : new String[]{application, testConfiguration}) { assertThat(configuration).contains( "max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}", "max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 28ea51299..b88483d96 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -170,6 +170,7 @@ class InboundUnloadCompletedOutboxRelayTest { private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); + row.setEventType(event.eventType()); row.setEventKey("OverseasContainerUnloaded:7001"); row.setPayloadJson(objectMapper.writeValueAsString(event)); return row; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java index 0d0c6e580..9751fd95c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java @@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; class DomesticPartnerWarehouseClientContractTest { @Test - void freezesCnInternalUrlTokenHeaderAndFourFieldDto() throws Exception { + void freezesWarehouseInternalUrlTokenHeaderAndFourFieldDto() throws Exception { FeignClient feign = DomesticPartnerWarehouseClient.class.getAnnotation(FeignClient.class); assertThat(feign.name()).isEqualTo("warehouse-service"); assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}"); @@ -30,22 +30,33 @@ class DomesticPartnerWarehouseClientContractTest { } @Test - void developmentDeploymentRequiresAndPassesTheCnUrlAndSharedInternalToken() throws Exception { + void testDeploymentPassesInternalWarehouseUrlAndSharedAuthentication() throws Exception { String application = Files.readString(Path.of("src/main/resources/application.yml")); - String nacos = Files.readString(Path.of("../config/nacos/overseas/lastleg-service.yml")); - String common = Files.readString(Path.of("../config/nacos/overseas/baiyue-common.yml")); - String compose = Files.readString(Path.of("../deploy/docker-compose.overseas.yml")); - String deploy = Files.readString(Path.of("../deploy/scripts/deploy-services.sh")); + String nacos = Files.readString(Path.of("../config/nacos/test/lastleg-service.yml")); + String common = Files.readString(Path.of("../config/nacos/test/baiyue-common.yml")); + String compose = Files.readString(Path.of("../deploy/docker-compose.yml")); + String deploy = Files.readString(Path.of("../deploy/scripts/deploy-built-service.sh")); assertThat(application).contains("url: ${DOMESTIC_WAREHOUSE_URL:}") .contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}"); assertThat(nacos).contains("url: ${DOMESTIC_WAREHOUSE_URL}") .contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}"); assertThat(common).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN}"); + String lastleg = compose.substring(compose.indexOf(" lastleg-service:"), compose.indexOf(" warehouse-overseas-service:")); + assertThat(lastleg).contains("<<: *common-environment") + .contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL:-http://warehouse:9205}") + .contains("expose: [\"9303\"]") + .doesNotContain("ports:"); assertThat(compose).contains("INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}") - .contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}"); - assertThat(deploy).contains("FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL") - .contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD " - + "OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN"); + .contains("NACOS_USERNAME: ${NACOS_USERNAME}") + .contains("NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual TEST namespace ID required}") + .contains("JWT_JWKS_URI: http://auth:9100/api/auth/.well-known/jwks.json") + .doesNotContain("OVERSEAS_NACOS_NAMESPACE", "OVERSEAS_NACOS_USERNAME", "FIRSTLEG_EVENT_BRIDGE_URL"); + assertThat(common).contains("session-mode: redis") + .contains("jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}"); + assertThat(deploy).contains("Only test is supported") + .contains("NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD") + .contains("REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN") + .contains("lastleg-service) db_prefix=LASTLEG"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java index a31c6267b..928c94501 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java @@ -75,8 +75,19 @@ class PdaWarehouseContractTest { "/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:", "/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path); assertThat(openapi).doesNotContain("/api/pda/v1/last-leg/inbound-plans/{planNo}"); - String gateway = Files.readString(Path.of("../gateway-overseas/src/main/resources/application-overseas.yml")); - assertThat(gateway).contains("/api/pda/v1/last-leg/**").contains("lb://lastleg-service"); + String gateway = Files.readString(Path.of("../gateway/src/main/resources/application.yml")); + String lastlegRoute = gateway.substring(gateway.indexOf("- id: lastleg-service"), + gateway.indexOf("- id: warehouse-overseas-service")); + assertThat(lastlegRoute).contains("uri: lb://lastleg-service") + .contains("/api/pda/v1/last-leg/**") + .contains("/api/platform/v1/last-leg/**") + .contains("/api/merchant/v1/last-leg/**"); + assertThat(gateway.lines().filter(line -> line.strip().startsWith("- Path="))) + .noneMatch(line -> line.contains("/internal/")); + assertThat(gateway).contains("allowed-headers: Authorization,") + .doesNotContain("gateway-overseas"); + String nacos = Files.readString(Path.of("../config/nacos/test/gateway.yml")); + assertThat(nacos).doesNotContain("routes:"); // Shared source routes must not be replaced by a partial regional list. } private static AuthenticatedUser user(Set authorities, Long warehouseId) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index 3cddb32a8..e7de9f255 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -29,7 +29,7 @@ class OutboxLeaseDatabaseTest { var repository = new UnloadCompletedOutboxRepository(); ReflectionTestUtils.setField(repository, "mapper", mapper); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3)); assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); @@ -66,7 +66,7 @@ class OutboxLeaseDatabaseTest { var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3)); assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java index e31035a1e..f27e39a69 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java @@ -69,16 +69,12 @@ class Phase5OverseasUpgradeSqlContractTest { } @Test - void overseasFixedAdminProjectionAssertionMatchesItsAuthorityArray() throws Exception { + void unifiedSessionDoesNotInitializeASecondAuthorizationProjection() throws Exception { String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql"); - int start = overseas.indexOf("VALUES ('AUTHORIZATION','1000001'"); - int end = overseas.indexOf(";", start); - String authorityRow = overseas.substring(start, end); - int authorityCount = count(authorityRow, "platform:"); - - assertThat(authorityCount).isPositive(); - assertThat(authorityRow).contains("baseline-platform-admin-authorization-v0"); - assertThat(overseas).doesNotContain("@platform_admin_overseas_authorities"); + assertThat(overseas).doesNotContain("CREATE TABLE ll_security_projection", "CREATE TABLE ll_security_event_inbox", + "baseline-platform-admin-authorization-v0", "@platform_admin_overseas_authorities"); + String primary = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); + assertThat(primary).contains("CREATE TABLE iam_security_subject_version", "CREATE TABLE iam_security_tenant_version"); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index 8e0607d16..bc57114ec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -39,7 +39,9 @@ class SchemaChineseCommentContractTest { List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 56); assertAllChineseComments(incremental, "增量 SQL", 50); - assertAllChineseComments(overseasUnified, "海外统一初始化 SQL", 127); + assertAllChineseComments(overseasUnified, "统一测试初始化 SQL", 126); + assertFalse(overseasUnified.contains("CREATE TABLE ll_security_projection")); + assertFalse(overseasUnified.contains("CREATE TABLE ll_security_event_inbox")); assertCommentMigrationCovers(incremental, overseasUnified, baselineTables); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index c6c9c3a00..6b714df95 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -25,7 +25,7 @@ class LoadingCompletedConsumerTest { String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item (")); assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } - @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } + @Test void rejectsUnsupportedEventType(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),"UnsupportedEvent",source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void freezesReceivingWarehouseInInitialInTransitInventory(){ service.consume(event()); diff --git a/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java b/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java index 13ac78693..d390c9b63 100644 --- a/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java +++ b/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java @@ -7,8 +7,14 @@ import org.springframework.boot.test.context.SpringBootTest; classes = PrintServiceApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { + "spring.config.location=optional:classpath:offline-print-test.yml", "spring.cloud.nacos.config.enabled=false", "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.server-addr=127.0.0.1:1", + "spring.cloud.nacos.username=offline", + "spring.cloud.nacos.password=offline", + "spring.cloud.nacos.discovery.namespace=offline-test", + "spring.cloud.nacos.config.namespace=offline-test", "baiyue.security.internal-service-token=test-internal-token-at-least-32-bytes" }) class PrintServiceApplicationTest { diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 0fb99aaa6..afb1f0ca0 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -11,8 +11,8 @@ | 类别 | 正式入口 | 管理边界 | |---|---|---| | 生产单票发布验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、前置同时核对东莞;订单创建/提交/审核与尾程签收/国内回传分阶段续跑,检查点独立;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | -| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | -| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 | +| 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 全量入口 | | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | diff --git a/scripts/README.md b/scripts/README.md index 203657136..21641aa81 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 + `db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 `db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 @@ -66,18 +68,10 @@ node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs o node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume ``` -开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库: - -| 环境 | 文件 | -|---|---| -| 开发环境 | `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env` | -| 测试环境 | `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env` | - -两份文件统一提供以下变量: +统一 TEST 验收账号、密码和 Gateway 由仓库外配置文件显式提供,例如 `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`,不写入仓库。文件提供以下变量: ```text ADMIN_TEST_GATEWAY -ADMIN_OVERSEAS_GATEWAY ADMIN_TEST_USERNAME ADMIN_TEST_PASSWORD MERCHANT_TEST_USERNAME @@ -91,9 +85,6 @@ Admin 默认全量及依赖国内装柜链的定向验收必须提供 `ADMIN_PDA 脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境: ```powershell -# 开发环境 -node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs - # 测试环境 node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs @@ -144,7 +135,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 订单与客户本地验收 | `phase1-acceptance.ps1` | 订单与客户本地服务/API 综合验收,会创建本地业务数据 | | 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 | | 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 | -| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 | +| 本地质量 | `backend-full-regression.ps1` | Gateway 路由与 Mapper 门禁后执行根 POM 全部有效模块 Maven 测试;`-DryRun` 只打印与校验 | | 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway | | 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | | Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 | @@ -157,7 +148,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | SQL 合同辅助 | `sql-baseline-contract-helpers.mjs` | 只供离线 Node 合同读取统一增量 SQL、拆分表定义和种子数据;不连接数据库,也不是 CLI | | OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 | | 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 | -| 本地环境 | `start-gateway-overseas-local.ps1` | 使用本地 Maven 启动海外 Gateway,并将海外安全投影地址指向本机;会启动本地 Java 进程,不访问或修改共享环境 | +| 本地环境 | `start-gateway-overseas-local.ps1` | 退役海外 Gateway 的遗留启动脚本,不属于当前统一 TEST 启动或回归入口;不得据此恢复海外 Gateway 部署 | | Nacos 环境选择 | `use-nacos-environment.ps1` | 仅设置当前进程 TEST 配置;显式提供地址、实际Namespace ID与凭据,不联网、不写运行环境 | | Nacos 配置发布 | `publish-nacos-config.ps1` | 仅发布 `config/nacos/test/*.yml`;必须指定实际Namespace ID,凭据从环境取得。`-DryRun`离线列清单;正式运行会写Nacos,需授权;失败停止,重跑完整匹配配置,不自动清理 | | 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 | @@ -282,10 +273,10 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 后端脚本固定运行: ```text -mvn -f <仓库根>/pom.xml -pl order,firstleg,iam,gateway,file,warehouse -am test +mvn -f <仓库根>/pom.xml test ``` -这会先构建公共依赖,再运行 `order`、`firstleg`、`iam`、`gateway`、`file` 与 `warehouse` 的全部 Maven 测试。覆盖包括: +这会按根 POM 的 reactor 拓扑执行全部有效模块测试(当前 20 个模块,包括 Auth、Finance、Lastleg、海外仓资源、Print 及公共模块),不维护服务子集白名单;根 POM 后续新增模块自动纳入。既有 Gateway 路由与 Mapper Node 门禁仍在 Maven 前执行。关键覆盖包括: - 国内货柜履约 Ocean Booking Controller/Service/Mapper/SQL 契约; - 国内货柜履约 Truck Appointment Controller/Service/Mapper 契约、状态机、乐观锁和幂等操作; diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index f76b6f140..252af883c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -286,7 +286,6 @@ function usage() { 选项: --gateway 必填,国内 Gateway 地址 - --overseas-gateway 四期海外模块必填,海外 Gateway 地址 --username 必填,管理员账号 --password 必填,管理员密码 --pda-username 国内装柜链必填,实际领取任务的 PDA 作业员账号 @@ -315,7 +314,7 @@ function usage() { --output JSON 报告路径,默认 reports/ --help 显示帮助 -也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, +也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) } @@ -336,6 +335,8 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } + if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway') + if (values.has('environment') && values.get('environment') !== 'test') throw new Error('Only test acceptance is supported') const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(',')) .split(',').map((v) => v.trim()).filter(Boolean) const modules = normalizeModuleOrder(requestedModules) @@ -354,8 +355,8 @@ export function parseArguments(argv) { if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) } return { + environment: 'test', gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), - overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '', @@ -396,10 +397,8 @@ export function validateExecutionOptions(options) { for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) { if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`) } - if (options.modules.some((module) => module.startsWith('phase4-overseas-')) - && (!options.overseasGateway || options.overseasGateway.trim() === '')) { - throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') - } + if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported') + if ('overseasGateway' in options) throw new Error('Use the single gateway option') if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module)) && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)') @@ -561,8 +560,7 @@ export class AcceptanceRun { this.token = '' this.pdaToken = '' this.refreshToken = '' - this.overseasToken = '' - this.overseasExpiresAt = 0 + this.expiresAt = 0 this.fatal = null } @@ -597,29 +595,6 @@ export class AcceptanceRun { } throw new Error('unreachable request attempt state') } - async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) { - const headers = { Accept: 'application/json', ...extraHeaders } - if (token) headers.Authorization = `Bearer ${token}` - if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' - try { - const response = await fetch(`${this.options.overseasGateway}${path}`, { - method, headers, body: body === undefined ? undefined : JSON.stringify(body), - signal: AbortSignal.timeout(30_000), - }) - const content = await response.text() - let payload = null - try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } - return { - method, path, http: response.status, body: payload, - raw: payload === null ? content.slice(0, 500) : null, - requestId: payload?.requestId || response.headers.get('x-request-id'), - traceId: payload?.traceId || response.headers.get('x-trace-id'), - } - } catch (error) { - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } - } - } - async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) { if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken const form = file instanceof FormData ? file : new FormData() @@ -631,31 +606,6 @@ export class AcceptanceRun { return this.requestWithBody(method, path, form, token, extraHeaders) } - async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) { - const form = file instanceof FormData ? file : new FormData() - if (!(file instanceof FormData)) { - if (!file || file.content === undefined) throw new TypeError('multipart file requires content') - const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) - form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') - } - const headers = { Accept: 'application/json', ...extraHeaders } - if (token) headers.Authorization = `Bearer ${token}` - try { - const response = await fetch(`${this.options.overseasGateway}${path}`, { - method, headers, body: form, signal: AbortSignal.timeout(60_000), - }) - const content = await response.text(); let payload = null - try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ } - return { method, path, http: response.status, body: payload, - raw: payload === null ? content.slice(0, 500) : null, - requestId: payload?.requestId || response.headers.get('x-request-id'), - traceId: payload?.traceId || response.headers.get('x-trace-id') } - } catch (error) { - return { method, path, http: 0, body: null, - raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } - } - } - async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` @@ -706,31 +656,6 @@ export class AcceptanceRun { throw new Error('unreachable binary request attempt state') } - async requestOverseasBinary(method, path) { - const headers = { Accept: '*/*' } - if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` - try { - const response = await fetch(`${this.options.overseasGateway}${path}`, { - method, headers, signal: AbortSignal.timeout(120_000), - }) - const bytes = new Uint8Array(await response.arrayBuffer()) - const contentType = response.headers.get('content-type') || '' - let payload = null - if (contentType.toLowerCase().includes('json')) { - try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ } - } - return { - method, path, http: response.status, body: payload, bytes, contentType, - raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`, - requestId: payload?.requestId || response.headers.get('x-request-id'), - traceId: payload?.traceId || response.headers.get('x-trace-id'), - } - } catch (error) { - return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '', - raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } - } - } - addStep(module, name, response, passed, evidence = '', options = {}) { const step = { module, scenario: options.scenario || null, name, passed: Boolean(passed), @@ -947,24 +872,16 @@ export class AcceptanceRun { async refreshSession(reason) { const response = await this.request('POST', '/api/auth/refresh', { refreshToken: this.refreshToken }, '') - const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response, - `reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) - const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) - && Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0 - this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid, - `accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`, - { scenario: 'dual-token-refresh', deploymentBlocker: true }) + const next = this.apiSuccess('authentication', '刷新统一会话', response, + `reason=${reason}`, { scenario: 'session-refresh', deploymentBlocker: true }) + const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) && Number(next?.expiresIn) > 0 + this.assert('authentication', '刷新响应完整后原子替换会话', response, valid, + `accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}`, + { scenario: 'session-refresh', deploymentBlocker: true }) if (!valid) return false - const replacement = { - token: next.accessToken, - refreshToken: next.refreshToken, - overseasToken: next.overseasAccessToken, - overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000, - } - this.token = replacement.token - this.refreshToken = replacement.refreshToken - this.overseasToken = replacement.overseasToken - this.overseasExpiresAt = replacement.overseasExpiresAt + this.token = next.accessToken + this.refreshToken = next.refreshToken + this.expiresAt = Date.now() + Number(next.expiresIn) * 1000 return true } @@ -1809,23 +1726,12 @@ export class AcceptanceRun { `tokenPresent=${Boolean(this.pdaToken)}`, { deploymentBlocker: true, scenario: 'phase3-pda' }) } this.refreshToken = login.refreshToken || '' - this.overseasToken = login.overseasAccessToken || '' - this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 - ? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0 - const requiresOverseas = this.options.modules.some((module) => - module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-')) - if (requiresOverseas) { - this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, - Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0, - `tokenPresent=${Boolean(this.overseasToken)}, expiresIn=${login.overseasExpiresIn ?? 'missing'}`, - { deploymentBlocker: true, scenario: 'phase4-dual-token' }) - } + this.expiresAt = Number(login.expiresIn) > 0 ? Date.now() + Number(login.expiresIn) * 1000 : 0 const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] - if ((module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-')) - && this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) { - await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`) + if (this.expiresAt > 0 && Date.now() + 120_000 >= this.expiresAt) { + await this.refreshSession(`模块 ${module} 执行前Token临近到期`) } const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name)) if (module === 'finance' && fullFinanceChain) { @@ -1913,12 +1819,11 @@ export class AcceptanceRun { if (module === 'system' && laterOverseasModule) { const refreshed = await this.refreshSession('system模块权限与角色变更完成') if (refreshed) { - const probe = await this.requestOverseas('GET', - '/api/platform/v1/last-leg/orders?page=1&size=1') - this.assert('authentication', 'system后新admin海外Token只读探针可用', probe, + const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1') + this.assert('authentication', 'system后新admin统一Token只读探针可用', probe, probe.http === 200 && String(probe.body?.code) === '200', `HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`, - { scenario: 'dual-token-refresh', deploymentBlocker: true }) + { scenario: 'session-refresh', deploymentBlocker: true }) } } } @@ -1929,7 +1834,7 @@ export class AcceptanceRun { createdResources: this.createdResources, }) const forbiddenValues = [this.options.password, this.options.merchantPassword, - this.token, this.refreshToken, this.overseasToken] + this.token, this.refreshToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, @@ -1946,7 +1851,7 @@ export class AcceptanceRun { const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps) return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, - gateway: this.options.gateway, overseasGateway: this.options.overseasGateway, + gateway: this.options.gateway, gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && summary.failed === 0, fatal: this.fatal, summary, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 3178c1d80..4b97b0a51 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -190,7 +190,7 @@ export async function run(context) { | 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 | | 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | -| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | +| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(统一平台会话、匿名拒绝、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询与诊断证据;重复 GET 仅验证读取稳定,缺真实投递重放证据仍阻断;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | | 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 | | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | @@ -202,7 +202,7 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 +海外仓业务 HTTP 验收使用 Auth 统一会话,仅通过 `--gateway` 访问 TEST Gateway;拒绝旧 `--overseas-gateway`、`ADMIN_OVERSEAS_GATEWAY` 和非 test 环境参数。平台、客户与 PDA 各用自己的账号访问令牌,权限、客户隔离及仓绑定仍须验证。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 访问令牌,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 @@ -217,7 +217,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost +node scripts/admin-test-env-acceptance.mjs --gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 @@ -340,3 +340,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra 国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。 + +`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。 diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 80f236ad0..939156479 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -30,11 +30,11 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', () assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s) }) -test('system后刷新双Token且受限海外权限只接受403语义', () => { +test('system后刷新统一Token且受限海外权限只接受403语义', () => { assert.match(acceptance, /module === 'system' && laterOverseasModule/) assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/) - assert.match(acceptance, /system后新admin海外Token只读探针可用/) - assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(acceptance, /system后新admin统一Token只读探针可用/) + assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.expiresAt/) assert.match(system, /缺少canonical海外订单权限必须403/) assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/) assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/) diff --git a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs index 9e36a4786..c8db998b8 100644 --- a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs +++ b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs @@ -34,7 +34,7 @@ function record(edge, name, response, passed, evidence = '') { } async function request(edge, method, path, body, pda = false) { - return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken) + return edge.context.request(method, path, body, pda ? edge.token : edge.context.token, {}) } async function candidates(edge) { diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index 157ce23ef..d82528c10 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -93,7 +93,7 @@ test('partial-pick rejection stays failed; a changed snapshot aborts further pic label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' }, pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' }, context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) }, - requestOverseas: async () => ({ body: { code: 'A0409' } }) }, + request: async () => ({ body: { code: 'A0409' } }) }, check: (_name, _response, passed) => assert.ok(passed), call: async () => structuredClone(snapshot), } diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index cdde8ec20..f5bbb2025 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -402,7 +402,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals const bookingId = String(loading.cabinet.id) const base = '/api/platform/v1/last-leg' async function call(name, method, path, body, headers) { - const response = await context.requestOverseas(method, path, body, headers) + const response = await context.request(method, path, body, undefined, headers) const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null) return data @@ -410,7 +410,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals let summary let summaryResponse for (let attempt = 0; attempt < 60; attempt++) { - const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`) + const response = await context.request('GET', `${base}/orders/container-summaries/${bookingId}`) summaryResponse = response summary = response?.body?.code === '200' ? response.body.data : null if (summary?.orders?.length === group.length) break @@ -463,14 +463,14 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals try { check(`${label}海外PDA账号和安全投影可用`, null, Boolean(picker?.token)) async function call(name, method, path, body) { - const response = await context.requestOverseas(method, path, body, {}, picker.token) + const response = await context.request(method, path, body, picker.token, {}) const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null) return data } let candidate for (let attempt = 0; attempt < 60; attempt++) { - const response = await context.requestOverseas('GET', `${base}/candidates`, undefined, {}, picker.token) + const response = await context.request('GET', `${base}/candidates`, undefined, picker.token, {}) candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo) if (candidate?.operable && candidate?.allPlansReady) break if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000)) @@ -486,7 +486,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals check(`${label}签收成功`, null, signed.status === 'SIGNED') if (mixedContainer) { const completed = await receiveMixedContainer({ group, call, check, marker: context.marker, - request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) }) + request: (method, path, body) => context.request(method, path, body, picker.token, {}) }) for (const item of group) { const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST', '/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode }) @@ -507,8 +507,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`) check(`${label}剩余托盘待收箱数准确`, null, Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted) - const premature = await context.requestOverseas('POST', `${base}/current/complete`, - { idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token) + const premature = await context.request('POST', `${base}/current/complete`, { idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, picker.token, {}) check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409') const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`) check(`${label}未齐完成拒绝后事实不变且可继续收货`, null, @@ -555,7 +554,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals const customerId = String(group[0].task.tenantId) const warehouseId = group[0].overseas.warehouseId async function call(name, method, path, body, pda = false) { - const response = await context.requestOverseas(method, path, body, {}, pda ? token : context.overseasToken) + const response = await context.request(method, path, body, pda ? token : context.token, {}) const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && value != null) return value diff --git a/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs index d7257fb53..3595ac9ee 100644 --- a/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs +++ b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs @@ -1,49 +1,34 @@ -export const EVENT_BRIDGE_RECOVERY_FIXTURE = Object.freeze({ - customerCode: 'C10000000', - eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', - initialOutboxStatus: 'PENDING', - initialRetryCount: 62, - failedTarget: '172.31.198.8:9313', - currentOutboxStatus: 'PENDING', - currentRetryCount: 81, - currentFailureCode: 'A0401', - currentFailureMessage: '内部服务令牌无效', - currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', - currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', - dockerDnsProven: true, +// Pure evidence checks; no HTTP, SQL or historical incident data. +export const TEST_DIRECT_DELIVERY_URLS = Object.freeze({ + LASTLEG_SERVICE_URL: 'http://lastleg-service:9303', + FIRSTLEG_SERVICE_URL: 'http://firstleg:9202', + FINANCE_SERVICE_URL: 'http://finance:9203', }) - -export const TEST_EVENT_BRIDGE_URLS = Object.freeze({ - LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', - FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', -}) - -export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze([ - 'firstleg', - 'event-bridge-cn', - 'event-bridge-overseas', - 'lastleg-service', -]) - -export function validTestEventBridgeEnvironment(values = {}) { - return Object.entries(TEST_EVENT_BRIDGE_URLS) - .every(([key, expected]) => values[key] === expected) - && Object.values(values).every((value) => !/^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::|\/|$)/.test(String(value))) +export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze(['firstleg', 'lastleg-service', 'finance']) +export function validDirectDeliveryEnvironment(values = {}) { + return Object.entries(TEST_DIRECT_DELIVERY_URLS).every(([key, url]) => values[key] === url) } - -// Callers must provide only a non-secret fingerprint/revision marker, never the token value. +// Only provide non-secret credential revision/fingerprints, never tokens. export function validInternalTokenCohort(fingerprints = {}) { - const values = INTERNAL_TOKEN_COHORT_SERVICES.map((service) => fingerprints[service]) - return values.every((value) => typeof value === 'string' && value.length > 0) - && new Set(values).size === 1 + const values = INTERNAL_TOKEN_COHORT_SERVICES.map(service => fingerprints[service]) + return values.every(value => typeof value === 'string' && value.trim().length > 0) && new Set(values).size === 1 } - -export function recoveredEventBridgeFacts(facts = {}) { - return facts.firstlegOutboxStatus === 'SENT' - && Number(facts.overseasBridgeEventCount) === 1 - && Number(facts.lastlegInboxEventCount) === 1 - && Number(facts.lastlegOrderCount) === 1 - && Number(facts.cnBridgeDuplicateCount) === 0 - && Number(facts.manualReplayCount) === 0 - && Number(facts.sqlMutationCount) === 0 +export function validDirectDeliveryEvidence(facts = {}) { + const attempts = facts.attempts + if (!facts.marker || !facts.eventId || !facts.payloadHash || !Array.isArray(attempts) || attempts.length < 1) return false + if (!Number.isInteger(facts.maxAttempts) || facts.maxAttempts < 1 || facts.sqlMutationCount !== 0) return false + if (!attempts.every(a => a.eventId === facts.eventId && a.payloadHash === facts.payloadHash + && Number.isFinite(Date.parse(a.at)) && a.requestId && a.traceId)) return false + const failures = attempts.filter(a => ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT'].includes(a.failure)) + if (failures.length === 0 || attempts.some(a => a.failure && a.outcome)) return false + if (failures.some(a => a.failure === 'TOKEN_REJECTED') && facts.status !== 'DEAD' + && facts.authorizedDeadRetry !== true) return false + const successful = attempts.filter(a => ['APPLIED','DUPLICATE','STALE'].includes(a.outcome)) + if (facts.status === 'DEAD') return successful.length === 0 && (failures.length === facts.maxAttempts + || (failures.length === 1 && failures[0].failure === 'TOKEN_REJECTED')) + && facts.automaticRetryScheduled === false + // A read-only snapshot alone can never prove a duplicate delivery happened. + return facts.status === 'SENT' && failures.length < facts.maxAttempts + && successful.some(a => a.outcome === 'APPLIED' && a.ackLost === true) && successful.some(a => a.outcome === 'DUPLICATE') + && facts.inboxEventCount === 1 && facts.businessEffectCount === 1 } diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs index e4fa6a647..df2ea3c86 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -29,7 +29,7 @@ function settlementKey(context, operation) { function write(context, method, path, body, operation, token = context.token, overseas = false) { const headers = { 'Idempotency-Key': key(context, operation) } return overseas - ? context.requestOverseas(method, path, body, headers, token) + ? context.request(method, path, body, token, headers) : context.request(method, path, body, token, headers) } @@ -105,7 +105,7 @@ async function loadActiveSupplierAndWarehouse(context, fixture) { async function publishDirectPaid(context, fixture, supplier, warehouse) { const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse) - const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true) + const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.token, true) const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse, diagnostic(createdResponse), OPTIONS) const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId }) @@ -116,21 +116,19 @@ async function publishDirectPaid(context, fixture, supplier, warehouse) { `sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS) if (!targetId) return null - const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true) + const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.token, true) const replay = data(replayResponse) context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse, ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId, `first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS) const conflictResponse = await write(context, 'POST', OVERSEAS_FEES, - { ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true) + { ...body, amount: body.amount + 1 }, 'overseas-create', context.token, true) context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse), diagnostic(conflictResponse), OPTIONS) - const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`, - { idempotencyKey: key(context, 'overseas-submit') }) + const submittedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/submit`, { idempotencyKey: key(context, 'overseas-submit') }) context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS) - const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`, - { decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') }) + const reviewedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') }) context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS) return { sourceFeeId: targetId, body } } diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs index ccbe7dbb7..d4c47053d 100644 --- a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -18,7 +18,7 @@ test('merged scenarios remain in default and focused dependency chains', () => { test('review helper refuses stale fixture before any request', async () => { const steps = [] await verifyWarehouseTransferReview({ marker: 'current', context: { merchantQualificationFixture: { marker: 'old' } }, - addStep: (...args) => steps.push(args), requestOverseas: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1') + addStep: (...args) => steps.push(args), request: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1') assert.equal(steps.length, 1) assert.equal(steps[0][3], false) assert.equal(steps[0][5].deploymentBlocker, true) @@ -46,7 +46,7 @@ test('draft and removal checks use actual IDs plus readback rather than snapshot test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => { const source = await read('phase5-overseas-fulfillment.mjs') - for (const value of ['requestOverseasBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery', + for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery', 'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map']) assert.ok(source.includes(value), value) }) diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index c3a878e98..246ce07a0 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -158,7 +158,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /carrierLatestActualAt/) assert.match(pickupChain, /pickup\.latestActualAt/) assert.match(pickupChain, /pickup\?\.marker === variantMarker/) - assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/) + assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|request/) assert.match(entry, /dropshipReady\?\.shippingReady === true/) assert.match(entry, /overseasReady\?\.shippingReady === true/) assert.match(entry, /awaitingVariants\.length >= 1/) diff --git a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs index 1abc83093..b68039627 100644 --- a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs +++ b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs @@ -36,8 +36,7 @@ function fixture(context) { } async function listOrders(context, containerNo) { - const response = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) return { response, rows: records(response?.body?.data).filter((row) => row?.containerNo === containerNo) } } @@ -46,8 +45,7 @@ async function snapshot(context, source) { const plans = [] for (const order of listed.rows) { if (!order?.inboundCode) continue - const response = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`) + const response = await context.request('GET', `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`) plans.push(...records(response?.body?.data).map((row) => ({ planId: id(row.planId), planNo: row.planNo, orderId: id(row.orderId), customerId: id(row.customerId), status: row.status, }))) @@ -66,16 +64,14 @@ function convertBody(source) { } async function convert(context, name, source, key) { - const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT, - convertBody(source), { 'Idempotency-Key': key }, context.overseasToken) + const response = await context.request('POST', CONTAINER_CONVERT_ENDPOINT, convertBody(source), context.token, { 'Idempotency-Key': key }) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return response } export async function run(context) { const data = fixture(context) - const summaryResponse = await context.requestOverseas('GET', - `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`) + const summaryResponse = await context.request('GET', `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`) context.assert(MODULE, 'P1服务端柜级摘要跨订单分页仍返回15票', summaryResponse, success(summaryResponse) && validContainerSummaryPage(summaryResponse.body?.data, { sourceContainerBookingId: data.normal.sourceContainerBookingId, @@ -132,8 +128,7 @@ export async function run(context) { && after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo), `planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS) - const readySummary = await context.requestOverseas('GET', - `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`) + const readySummary = await context.request('GET', `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`) context.assert(MODULE, 'P0全柜每票独立计划完成后allPlansReady', readySummary, success(readySummary) && validContainerSummaryPage(readySummary.body?.data, { sourceContainerBookingId: data.normal.sourceContainerBookingId, diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index e5a7988ad..978042d97 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -22,7 +22,7 @@ function context(resources = {}) { const gaps = [] const calls = [] return { - marker: 'MARKER-1', token: 'domestic-token', overseasToken: 'overseas-token', + marker: 'MARKER-1', token: 'platform-token', createdResources: resources, steps, gaps, calls, addCoverageGap(module, scenario, name, reason, category, metadata) { gaps.push({ module, scenario, name, reason, category, ...metadata }) @@ -34,24 +34,28 @@ function context(resources = {}) { assert(module, name, value, passed, evidence, options) { return this.addStep(module, name, value, passed, evidence, options) }, - async requestOverseas(method, path, body, headers, token) { - calls.push({ region: 'OVERSEAS', method, path, token: token || this.overseasToken }) - if (token === this.token) return response(path, null, 403, 'A0403') + + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,path,body,token,headers) { + calls.push({ region: 'OVERSEAS', method, path, token: token || this.token }) + if (token === '') return response(path, null, 401, 'A0401') if (path.includes('/last-leg/orders/9001')) { return response(path, { order: { id: '9001', containerNo: 'MSCU1234567' } }) } return response(path, { records: [{ id: '7001', containerNo: 'MSCU1234567' }] }) - }, - async request(method, path, body, token) { + } : async function(method,path,body,token) { calls.push({ region: 'CN', method, path, token: token || this.token }) - if (token === this.overseasToken) return response(path, null, 403, 'A0403') + if (token === '') return response(path, null, 401, 'A0401') if (path.includes('/overview')) return response(path, { container: { id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z', unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-27T10:00:00Z', actualEmptyReportedAt: null, emptyReportSourceVersion: 7, } }) return response(path, { records: [{ container: { id: '3001', containerNo: 'MSCU1234567' } }] }) - }, + }; + return handler.apply(this,args); + }, } } @@ -110,11 +114,11 @@ test('tail controls receive their own completed producer matrix before they cons assert.match(entry, /await this\.ensurePhase4PdaProducerMatrix\(\)/) }) -test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { +test('contract covers unified session, canonical 403, both event directions and diagnostics', () => { for (const token of [ - 'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered', + '无会话不得访问Lastleg', '同一平台会话可访问国内授权详情', 'cn-overseas-event-not-delivered', 'overseas-cn-event-not-delivered', 'requestId=', 'traceId=', 'pollRequests=', - '装柜完成事件重试保持建单幂等', '海外柜完成事件重试保持回写幂等', + '非事件重放证据', 'missing-direct-delivery-replay-evidence', ]) assert.ok(source.includes(token), token) assert.match(system, /缺少canonical海外订单权限必须403/) assert.match(system, /restrictedOverseasToken/) @@ -127,13 +131,14 @@ test('full observable cross-region flow performs requests and passes', async () phase4OverseasPda: { marker: 'MARKER-1', finalStatus: 'COMPLETED', customerId: '100', orderId: '9001' }, }) - await run(ctx) + await assert.rejects(() => run(ctx), /missing|真实投递重试与同事件幂等证据/) assert.ok(ctx.calls.length >= 8) - assert.equal(ctx.steps.every((step) => step.passed), true) - assert.equal(ctx.gaps.length, 0) - assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'overseas-token')) - assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'domestic-token')) + assert.equal(ctx.steps.filter((step) => !step.name.includes('真实投递')).every((step) => step.passed), true) + assert.equal(ctx.gaps.length, 1) + assert.equal(ctx.gaps[0].category, 'missing-direct-delivery-replay-evidence') + assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'platform-token')) + assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'platform-token')) }) test('missing marker fixture is a blocking coverage gap after real security request', async () => { diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events.mjs index 6df4b4c2a..c02993209 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events.mjs @@ -32,16 +32,12 @@ function sameIds(left, right) { } export async function run(context) { - if (!context.token || !context.overseasToken) { - blockingGap(context, '双Token前置', '国内或海外访问令牌缺失,不能执行跨区安全门禁', 'missing-dual-token') - } - - const domesticTokenAgainstOverseas = await context.requestOverseas('GET', - '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, {}, context.token) - context.assert(MODULE, '国内Token不能访问海外Audience', domesticTokenAgainstOverseas, - denied(domesticTokenAgainstOverseas) && traced(domesticTokenAgainstOverseas), - evidence(domesticTokenAgainstOverseas), OPTIONS) - + if (!context.token) blockingGap(context, '统一会话前置', '平台访问令牌缺失', 'missing-session') + const authorized = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1') + context.assert(MODULE, '统一平台会话可访问Lastleg授权列表', authorized, + success(authorized) && traced(authorized), evidence(authorized), OPTIONS) + const anonymous = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, '') + context.assert(MODULE, '无会话不得访问Lastleg', anonymous, denied(anonymous) && traced(anonymous), evidence(anonymous), OPTIONS) const phase3 = context.createdResources?.phase3Loading if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded || !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) { @@ -50,31 +46,29 @@ export async function run(context) { 'missing-marker-loading-completed') } - const overseasTokenAgainstDomestic = await context.request('GET', - `/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`, - undefined, context.overseasToken) - context.assert(MODULE, '海外Token不能访问国内Audience', overseasTokenAgainstDomestic, - denied(overseasTokenAgainstDomestic) && traced(overseasTokenAgainstDomestic), - evidence(overseasTokenAgainstDomestic), OPTIONS) + const domestic = await context.request('GET', + `/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`) + context.assert(MODULE, '同一平台会话可访问国内授权详情', domestic, + success(domestic) && traced(domestic), evidence(domestic), OPTIONS) const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}` const delivered = await poll( - () => context.requestOverseas('GET', taillegPath), + () => context.request('GET', taillegPath), (response) => success(response) && records(response.body?.data) .some((item) => item?.containerNo === phase3.containerNo)) const deliveredRows = records(delivered.response?.body?.data) .filter((item) => item?.containerNo === phase3.containerNo) - context.assert(MODULE, '国内装柜完成经Bridge生成Lastleg订单', delivered.response, + context.assert(MODULE, '国内装柜完成经Outbox直接投递生成Lastleg订单', delivered.response, deliveredRows.length > 0 && traced(delivered.response), evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS) if (deliveredRows.length === 0) { blockingGap(context, '国内到海外事件闭环', - `本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查Bridge重试与下游诊断`, + `本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查生产服务Outbox重试与下游诊断`, 'cn-overseas-event-not-delivered', delivered.response) } - const deliveredAgain = await context.requestOverseas('GET', taillegPath) - context.assert(MODULE, '装柜完成事件重试保持建单幂等', deliveredAgain, + const deliveredAgain = await context.request('GET', taillegPath) + context.assert(MODULE, '装柜结果重复读取稳定(非事件重放证据)', deliveredAgain, success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data) && traced(deliveredAgain), evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS) @@ -88,7 +82,7 @@ export async function run(context) { } const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}` - const orderDetail = await context.requestOverseas('GET', orderDetailPath) + const orderDetail = await context.request('GET', orderDetailPath) const order = orderDetail.body?.data?.order || orderDetail.body?.data const completedContainerNo = order?.containerNo || '' context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail, @@ -127,13 +121,13 @@ export async function run(context) { const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt) && Number.isFinite(estimatedAt) && completedAt >= uploadedAt && estimatedAt - completedAt === 72 * 60 * 60 * 1_000 - context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response, + context.assert(MODULE, '海外柜完成经Outbox直接投递回写Firstleg时间事实', writtenBack.response, validTimeline && traced(writtenBack.response), evidence(writtenBack.response, `pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS) if (!validTimeline) { blockingGap(context, '海外到国内事件闭环', - `柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查Bridge重试与诊断证据`, + `柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查生产服务Outbox重试与诊断证据`, 'overseas-cn-event-not-delivered', writtenBack.response) } @@ -141,8 +135,11 @@ export async function run(context) { const replaySnapshot = writtenBackAgain.body?.data?.container || {} const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt', 'actualEmptyReportedAt', 'emptyReportSourceVersion'] - context.assert(MODULE, '海外柜完成事件重试保持回写幂等', writtenBackAgain, + context.assert(MODULE, '卸柜时间事实重复读取稳定(非事件重放证据)', writtenBackAgain, success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null)) && traced(writtenBackAgain), evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS) + blockingGap(context, '真实投递重试与同事件幂等证据', + '两次GET仅证明读取稳定;需按event-outbox-operations.mjs的受保护内部运维流程,在本轮事件上验证故障后有限重试/DEAD及同eventId+payloadHash重复消费,保存逐次尝试与业务计数证据。不得重放SENT、改表或伪造新事件。', + 'missing-direct-delivery-replay-evidence') } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index 63a70ecf9..a345815dc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -41,17 +41,9 @@ function fixture(options = {}) { marker, calls, steps, gaps, blockers, context: { merchantQualificationFixture: credentials }, createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } }, - async request(method, requestPath, body) { - calls.push({ channel: 'domestic', method, path: requestPath, body }) - if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers)) - if (requestPath === '/api/auth/login') return response({ overseasAccessToken: 'merchant-overseas-token' }) - if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') - if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) { - state.warehouseStatus = 'INACTIVE'; return response(null) - } - throw new Error(`unexpected domestic ${method} ${requestPath}`) - }, - async requestOverseas(method, requestPath, body, headers, token) { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,requestPath,body,token,headers) { calls.push({ channel: 'overseas', method, path: requestPath, body, tokenPresent: Boolean(token) }) if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') { @@ -185,7 +177,19 @@ function fixture(options = {}) { return response(looseboardDetail()) } throw new Error(`unexpected ${method} ${requestPath}`) - }, + } : async function(method,requestPath,body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers)) + if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token' }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) { + state.warehouseStatus = 'INACTIVE'; return response(null) + } + throw new Error(`unexpected domestic ${method} ${requestPath}`) + }; + return handler.apply(this,args); + }, + apiSuccess(module, name, result, evidence, stepOptions) { const passed = result.http === 200 && String(result.body?.code) === '200' this.addStep(module, name, result, passed, evidence, stepOptions) @@ -215,7 +219,7 @@ test('module is context injected, blocks missing prerequisites and excludes forb for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) { assert.equal(source.includes(forbidden), false, forbidden) } - assert.doesNotMatch(source, /requestOverseas\('DELETE'/) + assert.doesNotMatch(source, /request\('DELETE'/) }) test('complete procurement truck and looseboard lifecycles pass with legal marker terminals', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index d0a361cdf..352fb78f0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -41,7 +41,7 @@ async function domestic(context, method, name, path, body) { } async function overseas(context, method, name, path, body) { - const response = await context.requestOverseas(method, path, body) + const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -96,17 +96,17 @@ async function loginMerchant(context, activeCustomer) { } const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS) - const overseasToken = auth?.overseasAccessToken - context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken), - `customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS) - return nonEmpty(overseasToken) ? overseasToken : null + const token = auth?.accessToken + context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token), + `customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS) + return nonEmpty(token) ? token : null } -async function createMerchantProcurement(context, activeCustomer, overseasToken) { +async function createMerchantProcurement(context, activeCustomer, token) { const path = '/api/merchant/v1/last-leg/overseas-procurements' const payload = procurementPayload(context, activeCustomer) - let activeToken = overseasToken - let response = await context.requestOverseas('POST', path, payload, {}, activeToken) + let activeToken = token + let response = await context.request('POST', path, payload, activeToken, {}) // A newly approved customer is projected to the overseas region asynchronously. The // domestic login can therefore succeed a few seconds before the overseas gateway has // received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact @@ -117,7 +117,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken) const refreshedToken = await loginMerchant(context, activeCustomer) if (!refreshedToken) break activeToken = refreshedToken - response = await context.requestOverseas('POST', path, payload, {}, activeToken) + response = await context.request('POST', path, payload, activeToken, {}) } const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS) const value = summary(detail) @@ -132,15 +132,14 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken) `orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS) if (!valid) return null - const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken) + const replayResponse = await context.request('POST', path, payload, activeToken, {}) const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS) const replay = summary(replayDetail) context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse, replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version), `orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS) - const mismatch = await context.requestOverseas('POST', path, - { ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken) + const mismatch = await context.request('POST', path, { ...payload, productName: `${payload.productName}-DIFFERENT` }, activeToken, {}) expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch) return { resource, payload } @@ -156,11 +155,11 @@ async function prerequisites(context) { const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response, `marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`) - const overseasToken = await loginMerchant(context, activeCustomer) - if (!overseasToken) return null + const token = await loginMerchant(context, activeCustomer) + if (!token) return null const partnerWarehouse = await createPartnerWarehouse(context) if (!partnerWarehouse) return null - const purchase = await createMerchantProcurement(context, activeCustomer, overseasToken) + const purchase = await createMerchantProcurement(context, activeCustomer, token) if (!purchase) return null const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`) @@ -181,7 +180,7 @@ async function prerequisites(context) { if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response, '采购仓候选为空,禁止跳过采购三态清空') return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions, - partnerWarehouse, purchase, overseasToken } + partnerWarehouse, purchase, token } } function assertProcurementHistory(context, result) { @@ -249,8 +248,7 @@ async function procurementFlow(context, prerequisite) { && Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1, `version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS) - const stale = await context.requestOverseas('PATCH', path, - { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' }) + const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' }) expectedConflict(context, '采购旧 version 返回 A0409', stale, true) const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path) const afterConflictSummary = summary(afterConflict.data) @@ -278,13 +276,9 @@ async function procurementFlow(context, prerequisite) { summary(rejectedReplay.data)?.status === 'REJECTED' && Number(summary(rejectedReplay.data)?.version) === Number(rejectedSummary?.version), trace(rejectedReplay.response), OPTIONS) - const reviewHashConflict = await context.requestOverseas('POST', - query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), - { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict) - const reverseReview = await context.requestOverseas('POST', - query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), - { version: Number(rejectedSummary?.version), decision: 'APPROVE', + const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE', idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255), remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview) @@ -333,8 +327,7 @@ async function assertCreateIdempotency(context, base, payload, customerId, label context.assert(MODULE, `${label}幂等重放复用订单且版本不漂移`, replay.response, replayValue?.orderNo === value.orderNo && Number(replayValue?.version) === Number(value.version), `orderNo=${id(replayValue?.orderNo) || 'missing'}, version=${replayValue?.version ?? 'missing'}; ${trace(replay.response)}`, OPTIONS) - const mismatch = await context.requestOverseas('POST', path, - { ...payload, destination: `${payload.destination}-DIFFERENT` }) + const mismatch = await context.request('POST', path, { ...payload, destination: `${payload.destination}-DIFFERENT` }) expectedConflict(context, `${label}创建同幂等键异载荷返回冲突`, mismatch) return { created, value, path } } @@ -406,8 +399,7 @@ async function truckFlow(context, customerId) { after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_REVIEW', `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) - const stale = await context.requestOverseas('PATCH', path, - { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + const stale = await context.request('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true) const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path) const afterStaleSummary = summary(afterStale.data) @@ -430,11 +422,9 @@ async function truckFlow(context, customerId) { summary(reviewReplay.data)?.status === 'WAITING_INBOUND' && Number(summary(reviewReplay.data)?.version) === Number(forwardedSummary?.version), trace(reviewReplay.response), OPTIONS) - const reviewMismatch = await context.requestOverseas('POST', reviewPath, - { ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + const reviewMismatch = await context.request('POST', reviewPath, { ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) expectedConflict(context, '卡车审核同键异载荷返回冲突', reviewMismatch) - const reversed = await context.requestOverseas('POST', reviewPath, - { version: Number(forwardedSummary?.version), decision: 'REJECT', + const reversed = await context.request('POST', reviewPath, { version: Number(forwardedSummary?.version), decision: 'REJECT', idempotencyKey: `${context.marker}-truck-review-terminal-reverse`.slice(0, 255), remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '卡车审核终态逆向返回冲突', reversed) @@ -483,8 +473,7 @@ async function looseboardFlow(context, customerId) { after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_ACCEPTANCE', `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) - const stale = await context.requestOverseas('PUT', path, - { ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + const stale = await context.request('PUT', path, { ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) expectedConflict(context, '散板旧 version 编辑返回 A0409', stale, true) const afterStale = await overseas(context, 'GET', '散板旧 version 冲突后零漂移回查', path) const afterStaleSummary = summary(afterStale.data) @@ -511,11 +500,9 @@ async function looseboardFlow(context, customerId) { summary(forwardReplay.data)?.status === 'DISPATCHED' && Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version), trace(forwardReplay.response), OPTIONS) - const forwardMismatch = await context.requestOverseas('PATCH', statusPath, - { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + const forwardMismatch = await context.request('PATCH', statusPath, { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch) - const reversed = await context.requestOverseas('PATCH', statusPath, - { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', + const reversed = await context.request('PATCH', statusPath, { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255), remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '散板状态逆向回退返回冲突', reversed) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index d5d99517b..ae66774fe 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -51,9 +51,9 @@ test('relabel transfer and discard cover create replay conflict list detail and test('customer isolation is exercised with merchant token, forged customerId and platform denial', () => { assert.match(source, /merchantQualificationFixture/) - assert.match(source, /overseasAccessToken/) + assert.match(source, /accessToken/) assert.match(source, /customerId: 0/) - assert.match(source, /平台海外Token不能冒充客户访问/) + assert.match(source, /平台统一Token不能冒充客户访问/) assert.match(source, /response\?\.http === 403.*codeOf\(response\) === 'A0403'/s) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 29231889c..6a8572cce 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -28,7 +28,7 @@ function query(path, values) { function future(days) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19) } async function merchant(context, token, method, name, path, body) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -51,9 +51,9 @@ async function loginMerchant(context) { loginId: fixture.username, password: fixture.password, }, '') const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS) - context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), - `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) - return nonBlank(auth?.overseasAccessToken) ? auth.overseasAccessToken : null + context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken), + `tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS) + return nonBlank(auth?.accessToken) ? auth.accessToken : null } async function currentOrders(context, token) { @@ -136,7 +136,7 @@ async function verifyRelabel(context, token, order) { const replay = await merchant(context, token, 'POST', '客户贴标申请幂等重放', RELABEL, body) context.assert(MODULE, '贴标重放复用同一申请', replay.response, id(replay.data?.application?.relabelNo) === relabelNo, trace(replay.response), OPTIONS) - const hashConflict = await context.requestOverseas('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) + const hashConflict = await context.request('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, token, {}) context.addStep(MODULE, '贴标同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) const page = await merchant(context, token, 'GET', '客户查询自身贴标申请列表', query(RELABEL, { page: 1, size: 20, relabelNo, customerId: 0 })) @@ -189,8 +189,7 @@ async function verifyTransfer(context, token, orders) { // reconcile from the authoritative detail before asserting the state. let submitted = null for (let attempt = 1; attempt <= 3; attempt += 1) { - const response = await context.requestOverseas('POST', - `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, {}, token) + const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {}) if (response?.http === 0 && attempt < 3) { await new Promise(resolve => setTimeout(resolve, 800 * attempt)) continue @@ -214,7 +213,7 @@ async function verifyTransfer(context, token, orders) { const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) context.assert(MODULE, '转运重放复用同一申请', replay.response, id(replay.data?.transferNo) === transferNo, trace(replay.response), OPTIONS) - const hashConflict = await context.requestOverseas('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) + const hashConflict = await context.request('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, token, {}) context.addStep(MODULE, '转运同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) const page = await merchant(context, token, 'GET', '客户查询自身仓储转运列表', query(TRANSFER, { page: 1, size: 20, transferNo, customerId: 0 })) @@ -231,7 +230,7 @@ async function verifyTransfer(context, token, orders) { } async function verifyDiscard(context, token, markerOrders) { - const response = await context.requestOverseas('GET', `${DISCARD}/inventory-candidates`, undefined, {}, token) + const response = await context.request('GET', `${DISCARD}/inventory-candidates`, undefined, token, {}) const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS) const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id))) const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId)) @@ -281,8 +280,8 @@ export async function run(context) { if (!token) return for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) { - const denied = await context.requestOverseas('GET', query(path, { page: 1, size: 1 })) - context.addStep(MODULE, `平台海外Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS) + const denied = await context.request('GET', query(path, { page: 1, size: 1 })) + context.addStep(MODULE, `平台统一Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS) } const orders = await currentOrders(context, token) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 5f5337eb9..d60708ea8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -32,7 +32,7 @@ function query(path, values) { } async function api(context, method, name, path, body) { - const response = await context.requestOverseas(method, path, body) + const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -159,7 +159,7 @@ export async function run(context) { success(removed.response) && success(removedDetail.response) && Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0 && removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS) - const removedAgain = await context.requestOverseas('DELETE', removePath) + const removedAgain = await context.request('DELETE', removePath) context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain, removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS) const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) @@ -235,7 +235,7 @@ export async function run(context) { `planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS) const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) - const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, { + const foreignLock = await context.request('POST', `${BASE}/draft-locks/lock`, { planPalletId, draftToken: foreignToken, }) context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) @@ -257,7 +257,7 @@ export async function run(context) { context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id) context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null - const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody) + const duplicateArrange = await context.request('POST', `${BASE}/pre-arrange`, preArrangeBody) context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange, success(duplicateArrange) && dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data), diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 619c25d38..15abb7e4c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -42,28 +42,11 @@ function fixture() { }, phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' }, }, - async request(method, path, body) { - calls.push({ region: 'CN', method, path, body }) - if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') { - return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] }) - } - if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'CN') - if (path.startsWith('/api/platform/finance/v1/fee-sources?')) { - const query = new URLSearchParams(path.split('?')[1]) - const customerId = query.get('customerId') - const keyword = query.get('keyword') - const rows = [...fees.values()].filter((fee) => fee.customerId === customerId - && (fee.inboundCode || fee.businessNo) === keyword) - .filter((fee) => fee.reviewStatus === 'APPROVED') - .map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG', - sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId })) - return response(rows) - } - throw new Error(`unexpected CN request ${method} ${path}`) - }, - async requestOverseas(method, path, body, headers = {}) { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,path,body,_token,headers = {}) { calls.push({ region: 'OVERSEAS', method, path, body, headers }) - if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'OVERSEAS') + if (path === '/internal/finance/event-operations/outbox') return response(null, '404', 404, 'OVERSEAS') for (const [base, row] of Object.entries(businessRows)) { if (method === 'GET' && path.startsWith(`${base}?`)) return response({ records: [row] }) } @@ -101,7 +84,28 @@ function fixture() { return response(fee) } throw new Error(`unexpected OVERSEAS request ${method} ${path}`) - }, + } : async function(method,path,body) { + calls.push({ region: 'CN', method, path, body }) + if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') { + return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] }) + } + if (path === '/internal/finance/event-operations/outbox') return response(null, '404', 404, 'CN') + if (path.startsWith('/api/platform/finance/v1/fee-sources?')) { + const query = new URLSearchParams(path.split('?')[1]) + const customerId = query.get('customerId') + const keyword = query.get('keyword') + const rows = [...fees.values()].filter((fee) => fee.customerId === customerId + && (fee.inboundCode || fee.businessNo) === keyword) + .filter((fee) => fee.reviewStatus === 'APPROVED') + .map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG', + sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId })) + return response(rows) + } + throw new Error(`unexpected CN request ${method} ${path}`) + }; + return handler.apply(this,args); + }, + apiSuccess(module, name, result, evidence, options) { const passed = result.http === 200 && String(result.body?.code) === '200' this.addStep(module, name, result, passed, evidence, options) @@ -126,7 +130,7 @@ test('module uses only injected real HTTP clients and never fabricates database/ 'OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE', 'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE', '/api/platform/v1/last-leg/finance-fee-sources', '/api/platform/finance/v1/fee-sources', - '/internal/event-bridge/status', + '/internal/finance/event-operations/outbox', ]) assert.ok(source.includes(token), token) }) @@ -163,10 +167,9 @@ test('five fee types traverse Lastleg lifecycle and reach Finance through real H assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed) assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed) assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed) - assert.equal(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).length, 5) - assert.ok(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).every((step) => step.passed)) - assert.ok(context.steps.find((step) => step.name === '国内 Event Bridge HTTP 能力状态可观察')?.passed) - assert.ok(context.steps.find((step) => step.name === '海外 Event Bridge HTTP 能力状态可观察')?.passed) + assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 5) + assert.ok(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).every((step) => step.passed)) + assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed) assert.deepEqual(context.steps.filter((step) => !step.passed), []) assert.equal(context.scenarios[0].deploymentBlocked, false) diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs index 96b565e71..f33e8fda0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -20,7 +20,7 @@ function blocker(context, name, reason, response = null) { } async function overseas(context, method, name, path, body, headers = {}) { - const response = await context.requestOverseas(method, path, body, headers) + const response = await context.request(method, path, body, undefined, headers) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -147,8 +147,7 @@ async function createApprove(context, definition, fixture, index) { id(replay.data?.sourceFeeId) === sourceFeeId && Number(replay.data?.sourceVersion) === 1, trace(replay.response), OPTIONS) if (index === 0) { - const mismatch = await context.requestOverseas('POST', LASTLEG_BASE, - { ...payload, amount: payload.amount + 1 }, { 'Idempotency-Key': key }) + const mismatch = await context.request('POST', LASTLEG_BASE, { ...payload, amount: payload.amount + 1 }, undefined, { 'Idempotency-Key': key }) context.addStep(MODULE, '费用创建同键异载荷冲突', mismatch, conflict(mismatch), trace(mismatch), OPTIONS) } @@ -189,8 +188,7 @@ async function verifyCustomerIsolation(context, fixtures, source) { `otherCustomerId=${id(other.id)}, leaked=${rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId)}; ${trace(wrongList.response)}`, OPTIONS) if (source.payload.businessId) { const wrongPayload = { ...source.payload, customerId: id(other.id), customerName: other.tenantName || other.name || 'other' } - const wrong = await context.requestOverseas('POST', LASTLEG_BASE, wrongPayload, - { 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) }) + const wrong = await context.request('POST', LASTLEG_BASE, wrongPayload, undefined, { 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) }) context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS) } } @@ -205,7 +203,7 @@ async function verifyFinanceDelivery(context, sources) { && id(item?.sourceFeeId) === source.sourceFeeId) return { response, found } }, (value) => success(value?.response) && Boolean(value?.found)) - context.assert(MODULE, `${source.definition.label}费用经跨区链进入 Finance`, delivered.result?.response, + context.assert(MODULE, `${source.definition.label}费用经业务Outbox直接投递进入 Finance`, delivered.result?.response, Boolean(delivered.result?.found) && id(delivered.result.found.customerId) === id(source.customerId) && delivered.result.found.sourceType === source.definition.sourceType, @@ -213,15 +211,10 @@ async function verifyFinanceDelivery(context, sources) { } } -async function verifyBridgeCapabilities(context) { - const domestic = await context.request('GET', '/internal/event-bridge/status') - context.addStep(MODULE, '国内 Event Bridge HTTP 能力状态可观察', domestic, - (success(domestic) && String(domestic.body?.data?.region).toUpperCase() === 'CN') || domestic.http === 404, - `${domestic.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(domestic)}`, OPTIONS) - const overseas = await context.requestOverseas('GET', '/internal/event-bridge/status') - context.addStep(MODULE, '海外 Event Bridge HTTP 能力状态可观察', overseas, - (success(overseas) && String(overseas.body?.data?.region).toUpperCase() === 'OVERSEAS') || overseas.http === 404, - `${overseas.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(overseas)}`, OPTIONS) +async function verifyInternalBoundary(context) { + const response = await context.request('GET', '/internal/finance/event-operations/outbox') + context.assert(MODULE, '统一Gateway不公开内部事件运维接口', response, + [401, 403, 404].includes(response.http), trace(response), OPTIONS) } export async function run(context) { @@ -251,7 +244,7 @@ export async function run(context) { `sourceFeeId=${warehouseSource?.sourceFeeId || 'missing'}, warehouseId=${warehouseSource?.payload?.warehouseId || 'missing'}`, OPTIONS) await verifyCustomerIsolation(context, fixtures, sources.find((item) => item.payload.businessId)) await verifyFinanceDelivery(context, sources) - await verifyBridgeCapabilities(context) + await verifyInternalBoundary(context) context.createdResources.phase4OverseasFinanceCrossRegion = { marker: context.marker, sources: sources.map((item) => ({ sourceFeeId: item.sourceFeeId, customerId: item.customerId, diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index da39febde..a660ab7e5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -16,7 +16,7 @@ function query(path, values) { function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) } async function request(context, method, name, path, body, headers = {}) { - const response = await context.requestOverseas(method, path, body, headers) + const response = await context.request(method, path, body, undefined, headers) context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS) return ok(response) ? response.body?.data : null } @@ -44,17 +44,12 @@ async function inboundAdminLifecycle(context) { let preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, { sourceContainerBookingId: bookingId, })) - const arrivalQuery = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/orders/container-arrival-times/query', - { sourceContainerBookingIds: [bookingId] }) + const arrivalQuery = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }) const arrival = ok(arrivalQuery) && Array.isArray(arrivalQuery.body?.data) ? arrivalQuery.body.data.find((item) => id(item?.sourceContainerBookingId) === bookingId) : null if (arrival && !arrival.estimatedWarehouseArrivalAt) { - await context.requestOverseas('PUT', - `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, - { estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19), - expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` }, - { 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) }) + await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19), + expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` }, undefined, { 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) }) preview = await request(context, 'GET', '刷新补齐时间后的入仓计划预览', query(`${PLAN_BASE}/container-preview`, { sourceContainerBookingId: bookingId, })) @@ -108,7 +103,7 @@ async function inboundAdminLifecycle(context) { id(appointmentReplay?.id) === id(appointment?.id) && appointmentReplay?.items?.length === fbaLines.length, `appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS) - const appointmentConflict = await context.requestOverseas('POST', `${PLAN_BASE}/fba-appointments/batch`, { + const appointmentConflict = await context.request('POST', `${PLAN_BASE}/fba-appointments/batch`, { ...appointmentBody, appointmentRemark: `${context.marker}-different`, }) context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict, @@ -171,7 +166,7 @@ async function inventoryCommandLifecycle(context) { context.assert(MODULE, '库存冻结重放不重复扣减', null, held?.operationType === 'HOLD' && heldReplay?.operationId === held?.operationId && heldReplay?.replayed === true, `operationId=${id(held?.operationId) || 'missing'}, replayed=${heldReplay?.replayed}`, OPTIONS) - const holdConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/hold`, { + const holdConflict = await context.request('POST', `${INVENTORY_BASE}/hold`, { ...holdBody, remark: `${context.marker}-different`, }) context.addStep(MODULE, '库存冻结同键异载荷返回A0409', holdConflict, @@ -194,7 +189,7 @@ async function inventoryCommandLifecycle(context) { increased?.operationType === 'ADJUST' && increaseReplay?.operationId === increased?.operationId && increaseReplay?.replayed === true, `operationId=${id(increased?.operationId) || 'missing'}, replayed=${increaseReplay?.replayed}`, OPTIONS) - const adjustConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/adjust`, { + const adjustConflict = await context.request('POST', `${INVENTORY_BASE}/adjust`, { ...increaseBody, reason: 'DIFFERENT_COUNT_REASON', }) context.addStep(MODULE, '盘点调整同键异载荷返回A0409', adjustConflict, diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 641fadaad..5d1c559ef 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -56,46 +56,9 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { addCoverageGap(module, scenario, name, reason, category, metadata = {}) { coverageGaps.push({ module, scenario, name, reason, category, ...metadata }) }, - async request(method, path, body) { - calls.push({ gateway: 'domestic', method, path, body }) - assert.notEqual(method, 'DELETE') - if (method === 'POST' && path === DOMESTIC_BASE) { - state.warehouse = { ...body, id: '910000000000000001' } - return ok(state.warehouse.id) - } - if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) { - return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities }) - } - if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) { - state.warehouse = { ...body, id: state.warehouse.id } - return ok(null) - } - if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) { - state.warehouse.status = 'INACTIVE' - return ok(null) - } - if (method === 'POST' && path === '/api/platform/v1/iam/roles') { - state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' } - return ok({ ...state.role }) - } - if (method === 'POST' && path === '/api/platform/v1/iam/users') { - state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' } - return ok({ ...state.user }) - } - if (method === 'POST' && path === '/api/auth/login') { - return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 }) - } - if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') { - state.user.status = body.status - return ok(null) - } - if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') { - state.role.status = body.status - return ok(null) - } - assert.fail(`unexpected domestic call ${method} ${path}`) - }, - async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,path,body,token = 'admin-overseas-token',extraHeaders = {}) { calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) assert.notEqual(method, 'DELETE') if (token === 'deny-overseas-token') return forbidden() @@ -189,8 +152,49 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 }) } assert.fail(`unexpected overseas call ${method} ${path}`) - }, - async requestOverseasBinary(method, path) { + } : async function(method,path,body) { + calls.push({ gateway: 'domestic', method, path, body }) + assert.notEqual(method, 'DELETE') + if (method === 'POST' && path === DOMESTIC_BASE) { + state.warehouse = { ...body, id: '910000000000000001' } + return ok(state.warehouse.id) + } + if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) { + return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities }) + } + if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) { + state.warehouse = { ...body, id: state.warehouse.id } + return ok(null) + } + if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) { + state.warehouse.status = 'INACTIVE' + return ok(null) + } + if (method === 'POST' && path === '/api/platform/v1/iam/roles') { + state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.role }) + } + if (method === 'POST' && path === '/api/platform/v1/iam/users') { + state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.user }) + } + if (method === 'POST' && path === '/api/auth/login') { + return ok({ accessToken: 'deny-overseas-token', expiresIn: 900 }) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') { + state.user.status = body.status + return ok(null) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') { + state.role.status = body.status + return ok(null) + } + assert.fail(`unexpected domestic call ${method} ${path}`) + }; + return handler.apply(this,args); + }, + + async requestBinary(method, path) { calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) @@ -208,10 +212,10 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { assert.match(source, /context\.request\('POST', DOMESTIC_BASE/) assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/) - assert.match(source, /context\.requestOverseas\('POST'/) - assert.match(source, /context\.requestOverseas\('PUT'/) - assert.match(source, /context\.requestOverseas\('PATCH'/) - assert.match(source, /context\.requestOverseasBinary\('GET'/) + assert.match(source, /context\.request\('POST'/) + assert.match(source, /context\.request\('PUT'/) + assert.match(source, /context\.request\('PATCH'/) + assert.match(source, /context\.requestBinary\('GET'/) assert.match(source, /context\.expectConflict/) assert.match(source, /warehouse-options\?refresh=true/) assert.match(source, /空候选不负缓存30分钟/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index d2e5c4e48..8230c10db 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -111,7 +111,7 @@ async function createAndVerifyPartnerWarehouse(context, resource) { } async function captureWarehouseOptionsBeforeCreate(context, resource) { - const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const response = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response) context.assert(MODULE, '合作仓候选基线为规范数组', response, Array.isArray(options) && options.every((item) => asId(item?.warehouseId) @@ -144,7 +144,7 @@ function quoteUpdatePayload(created, context, version) { } async function quoteLifecycle(context, resource) { - const optionsResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const optionsResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) const options = requireData(context, '查询海外报价合作仓候选', optionsResponse) context.assert(MODULE, '报价候选只来自海外正式接口', optionsResponse, Array.isArray(options) && options.every((item) => asId(item.warehouseId) @@ -156,37 +156,35 @@ async function quoteLifecycle(context, resource) { optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER) } - const refreshResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) + const refreshResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse) const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option), `createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER) if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`) - const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const cachedResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse) context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse, JSON.stringify(cached) === JSON.stringify(refreshed), `refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER) const payload = quoteCreatePayload(context, asId(option.warehouseId)) - const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) + const createResponse = await context.request('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) let detail = requireData(context, '新增海外报价', createResponse) const id = asId(detail?.summary?.id) if (!id || !recordVersion(detail?.summary?.recordVersion)) throw new Error('报价创建响应缺少字符串ID或recordVersion') resource.quote = { id, warehouseId: payload.warehouseId, recordVersion: Number(detail.summary.recordVersion), finalStatus: detail.summary.status } const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(id)}` - const detailResponse = await context.requestOverseas('GET', path) + const detailResponse = await context.request('GET', path) detail = requireData(context, '查询海外报价详情', detailResponse) context.assert(MODULE, '报价详情长ID、费用和版本', detailResponse, asId(detail?.summary?.id) === id && recordVersion(detail?.summary?.recordVersion) && Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0, `id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER) - const listResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`) + const listResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`) const page = requireData(context, '分页查询marker海外报价', listResponse) context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse, Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), @@ -194,22 +192,22 @@ async function quoteLifecycle(context, resource) { const staleVersion = Number(detail.summary.recordVersion) const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion) - const updateResponse = await context.requestOverseas('PUT', path, updatePayload) + const updateResponse = await context.request('PUT', path, updatePayload) detail = requireData(context, '编辑海外报价', updateResponse) resource.quote.recordVersion = Number(detail.summary.recordVersion) context.assert(MODULE, '报价编辑版本递增', updateResponse, detail.summary.name === updatePayload.name && resource.quote.recordVersion === staleVersion + 1, `before=${staleVersion}, after=${resource.quote.recordVersion}`, BLOCKER) - const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, name: `${context.marker} stale` }) + const conflictResponse = await context.request('PUT', path, { ...updatePayload, name: `${context.marker} stale` }) context.expectConflict(MODULE, '报价旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario) - const afterConflictResponse = await context.requestOverseas('GET', path) + const afterConflictResponse = await context.request('GET', path) detail = requireData(context, '报价冲突后重新查询', afterConflictResponse) context.assert(MODULE, '报价冲突未覆盖新值', afterConflictResponse, detail.summary.name === updatePayload.name && Number(detail.summary.recordVersion) === resource.quote.recordVersion, `name=${detail.summary.name}, version=${detail.summary.recordVersion}`, BLOCKER) - const activateResponse = await context.requestOverseas('PATCH', `${path}/status`, { + const activateResponse = await context.request('PATCH', `${path}/status`, { status: 'ACTIVE', recordVersion: resource.quote.recordVersion, }) detail = requireData(context, '启用海外报价', activateResponse) @@ -218,7 +216,7 @@ async function quoteLifecycle(context, resource) { context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse, detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) - const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) + const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) assertBinaryCsv(context, '导出海外报价CSV', csv, '报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间', [id, context.marker]) @@ -232,7 +230,7 @@ async function chooseSafeExchangeMonth(context) { const serial = ((seed + offset) % 120) const month = `${2090 + Math.floor(serial / 12)}-${String((serial % 12) + 1).padStart(2, '0')}` const path = `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}` - const response = await context.requestOverseas('GET', path) + const response = await context.request('GET', path) const page = requireData(context, `探测安全汇率月份${month}`, response) if (Array.isArray(page?.records) && page.records.length === 0 && Number(page.total) === 0) return month } @@ -242,12 +240,12 @@ async function chooseSafeExchangeMonth(context) { async function exchangeLifecycle(context, resource) { const month = await chooseSafeExchangeMonth(context) const createPayload = { month, usdToCnyAverage: 7.123456, remark: `${context.marker} safe marker month` } - const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload) + const createResponse = await context.request('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload) let rate = requireData(context, '新增安全marker月汇率', createResponse) resource.exchangeRate = { month, recordVersion: Number(rate.recordVersion), finalStatus: rate.status } const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(month)}` - const detailResponse = await context.requestOverseas('GET', path) + const detailResponse = await context.request('GET', path) rate = requireData(context, '查询marker月汇率详情', detailResponse) context.assert(MODULE, '汇率详情月份、decimal与版本', detailResponse, rate.month === month && Number(rate.usdToCnyAverage) > 0 && recordVersion(rate.recordVersion), @@ -255,7 +253,7 @@ async function exchangeLifecycle(context, resource) { const staleVersion = Number(rate.recordVersion) const updatePayload = { recordVersion: staleVersion, usdToCnyAverage: 7.234567, remark: `${context.marker} overwritten once` } - const updateResponse = await context.requestOverseas('PUT', path, updatePayload) + const updateResponse = await context.request('PUT', path, updatePayload) rate = requireData(context, '覆盖维护marker月汇率', updateResponse) resource.exchangeRate.recordVersion = Number(rate.recordVersion) context.assert(MODULE, '汇率覆盖版本递增', updateResponse, @@ -263,16 +261,16 @@ async function exchangeLifecycle(context, resource) { && resource.exchangeRate.recordVersion === staleVersion + 1, `before=${staleVersion}, after=${resource.exchangeRate.recordVersion}`, BLOCKER) - const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 }) + const conflictResponse = await context.request('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 }) context.expectConflict(MODULE, '汇率旧recordVersion覆盖冲突', conflictResponse, BLOCKER.scenario) - const afterConflictResponse = await context.requestOverseas('GET', path) + const afterConflictResponse = await context.request('GET', path) rate = requireData(context, '汇率冲突后重新查询', afterConflictResponse) context.assert(MODULE, '汇率冲突未覆盖新值', afterConflictResponse, Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage && Number(rate.recordVersion) === resource.exchangeRate.recordVersion, `rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER) - const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, { + const statusResponse = await context.request('PATCH', `${path}/status`, { status: 'INACTIVE', recordVersion: resource.exchangeRate.recordVersion, remark: `${context.marker} lifecycle complete`, }) @@ -282,14 +280,13 @@ async function exchangeLifecycle(context, resource) { context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse, rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER) - const listResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`) + const listResponse = await context.request('GET', `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`) const page = requireData(context, '分页查询marker月汇率', listResponse) context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse, Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)), `month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER) - const csv = await context.requestOverseasBinary('GET', + const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) assertBinaryCsv(context, '导出marker月汇率CSV', csv, '月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE']) @@ -311,14 +308,14 @@ function vehiclePayload(context, edited = false) { async function vehicleLifecycle(context, resource) { const createPayload = vehiclePayload(context) - const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/vehicles`, createPayload) + const createResponse = await context.request('POST', `${OVERSEAS_BASE}/vehicles`, createPayload) let vehicle = requireData(context, '新增marker车辆', createResponse) const id = asId(vehicle.id) if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion') resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status } const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}` - const detailResponse = await context.requestOverseas('GET', path) + const detailResponse = await context.request('GET', path) vehicle = requireData(context, '查询marker车辆详情', detailResponse) context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse, asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status) @@ -327,7 +324,7 @@ async function vehicleLifecycle(context, resource) { const staleVersion = Number(vehicle.recordVersion) const updatePayload = { recordVersion: staleVersion, ...vehiclePayload(context, true) } - const updateResponse = await context.requestOverseas('PUT', path, updatePayload) + const updateResponse = await context.request('PUT', path, updatePayload) vehicle = requireData(context, '编辑marker车辆', updateResponse) const updatedVersion = Number(vehicle.recordVersion) const updatedMileage = Number(vehicle.mileageKm) @@ -337,15 +334,15 @@ async function vehicleLifecycle(context, resource) { && updatedVersion === Number(staleVersion) + 1, `before=${staleVersion}, after=${updatedVersion}, mileage=${updatedMileage}`, BLOCKER) - const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, model: 'stale overwrite' }) + const conflictResponse = await context.request('PUT', path, { ...updatePayload, model: 'stale overwrite' }) context.expectConflict(MODULE, '车辆旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario) - const afterConflictResponse = await context.requestOverseas('GET', path) + const afterConflictResponse = await context.request('GET', path) vehicle = requireData(context, '车辆冲突后重新查询', afterConflictResponse) context.assert(MODULE, '车辆冲突未覆盖新值', afterConflictResponse, vehicle.model === updatePayload.model && Number(vehicle.recordVersion) === resource.vehicle.recordVersion, `model=${vehicle.model}, version=${vehicle.recordVersion}`, BLOCKER) - const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, { + const statusResponse = await context.request('PATCH', `${path}/status`, { status: 'MAINTENANCE', recordVersion: resource.vehicle.recordVersion, }) vehicle = requireData(context, '车辆切换安全维护状态', statusResponse) @@ -354,14 +351,13 @@ async function vehicleLifecycle(context, resource) { context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse, vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER) - const listResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) + const listResponse = await context.request('GET', `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) const page = requireData(context, '分页查询marker车辆', listResponse) context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse, Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) - const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) + const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) assertBinaryCsv(context, '导出marker车辆CSV', csv, '车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间', [id, createPayload.plateNumber, 'MAINTENANCE']) @@ -392,9 +388,8 @@ async function verifyCanonicalPermissionDenial(context, resource) { const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const login = requireData(context, '无主数据权限用户登录', loginResponse) - if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token') - const denied = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken) + if (!login?.accessToken) throw new Error('无主数据权限用户未签发统一Token') + const denied = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, login.accessToken, {}) assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied) } @@ -409,7 +404,7 @@ async function cleanupPartnerWarehouse(context, resource) { async function cleanupVersioned(context, resource, key, path, extract) { const current = resource[key] if (!current) return - const detailResponse = await context.requestOverseas('GET', path) + const detailResponse = await context.request('GET', path) const detail = data(detailResponse) const value = extract(detail) if (!success(detailResponse) || !value || !recordVersion(value.recordVersion)) { @@ -420,7 +415,7 @@ async function cleanupVersioned(context, resource, key, path, extract) { current.recordVersion = Number(value.recordVersion) current.finalStatus = value.status if (value.status === 'INACTIVE') return - const response = await context.requestOverseas('PATCH', `${path}/status`, { + const response = await context.request('PATCH', `${path}/status`, { status: 'INACTIVE', recordVersion: current.recordVersion, ...(key === 'exchangeRate' ? { remark: `${context.marker} finally cleanup` } : {}), }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index f873cb56c..89392a80b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -31,7 +31,7 @@ test('dropship original counted cartons must resolve exactly before any receivin for (const fault of [null, 'missing-carton', 'wrong-carton', 'wrong-inbound', 'wrong-destination']) { const calls = []; const checks = [] const context = { - async requestOverseas(method, requestPath, body) { + async request(method,requestPath,body) { calls.push({ method, requestPath, body }) const data = { cartonMark: body.cartonMark, inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS' } if (fault === 'wrong-carton') data.cartonMark = 'ANOTHER-CARTON' @@ -60,7 +60,7 @@ test('dropship original counted cartons must resolve exactly before any receivin for (const sealedCartonMarks of [undefined, [], ['REAL-INBOUND#2-1'], ['REAL-INBOUND#2-1', 'REAL-INBOUND#2-1']]) { const checks = [] const result = await resolveCartonFixtures({ assert(m,n,r,passed) { checks.push(passed) }, - async requestOverseas() { assert.fail('incomplete physical fixture must not issue HTTP') } }, + async request() { assert.fail('incomplete physical fixture must not issue HTTP') } }, 'token', inbounds, producer.sealedCartonMarks, { ...producer, sealedCartonMarks }) assert.equal(result, null) assert.deepEqual(checks, [false]) @@ -91,8 +91,8 @@ test('current loading event binds each pallet carton group to an explicit source test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => { assert.match(source, /export async function run\(context\)/) - assert.match(source, /requestOverseas\(/) - assert.match(source, /requestOverseasMultipart\(/) + assert.match(source, /request\(/) + assert.match(source, /requestMultipart\(/) assert.match(source, /requestId=/) assert.match(source, /traceId=/) assert.doesNotMatch(source, /https?:\/\//) @@ -140,8 +140,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /安全扫描返回A0400/) assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/) assert.match(source, /193000000000200003.*193000000000200004/) - assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/) - assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/) + assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/) + assert.doesNotMatch(source, /request\('GET', '\/api\/platform\/v1\/warehouse-admin'/) assert.match(source, /receiving-containers\/current\/pallets\/resolve/) assert.match(source, /overseasWarehouseId, password/) assert.match(source, /inbound\?\.overseasWarehouseId/) @@ -280,13 +280,17 @@ test('missing OVERSEAS_WAREHOUSE_PDA_OPERATOR overseas authority is a deployment const calls = []; const gaps = []; const steps = [] const context = { marker: 'PDA-CONTRACT', context: {}, createdResources: {}, - async request(method, requestPath) { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(...args) { calls.push(args); throw new Error('must not request without credentials') } : async function(method,requestPath) { calls.push({ method, requestPath }) return { method, path: requestPath, http: 200, body: { code: '200', data: [] }, requestId: 'request-contract-1', traceId: 'trace-contract-0001' } - }, - async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') }, - async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') }, + }; + return handler.apply(this,args); + }, + + async requestMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') }, apiSuccess(module, name, response) { return response.body.data }, addStep(...args) { steps.push(args) }, assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, @@ -306,9 +310,16 @@ test('empty candidate response cannot become a zero-business-request pass', asyn let userSequence = 0 const roleId = '4000005'; const permissionId = '5107015' const context = { - marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {}, + marker: 'PDA-CONTRACT', token: '', createdResources: {}, context: {}, async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` }, - async request(method, requestPath, body) { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,requestPath,body,token,headers) { + calls.push({ channel: 'overseas', method, requestPath }) + if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, + body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } + return response([], requestPath, method) + } : async function(method,requestPath,body) { calls.push({ channel: 'domestic', method, requestPath }) if (method === 'GET' && requestPath.endsWith('/roles')) return response([ { id: roleId, roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', status: 'ACTIVE', permissionIds: [permissionId] }, @@ -324,16 +335,13 @@ test('empty candidate response cannot become a zero-business-request pass', asyn membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE', }, requestPath, method) - if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method) + if (method === 'POST' && requestPath === '/api/auth/login') return response({ accessToken: `pda-token-${userSequence}` }, requestPath, method) if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method) throw new Error(`unexpected domestic request ${method} ${requestPath}`) - }, - async requestOverseas(method, requestPath, body, headers, token) { - calls.push({ channel: 'overseas', method, requestPath }) - if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, - body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } - return response([], requestPath, method) - }, + }; + return handler.apply(this,args); + }, + apiSuccess(module, name, result) { return result.body.data }, addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 052e5d152..4753aff30 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -103,7 +103,7 @@ test('PDA operations reuse received plan pallets and the public location without }) test('P5 follows warehouse review, customer confirmation and fulfillment completion in order', () => { - assert.match(source, /request\(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK'/) + assert.match(source, /request\(context, context.token, 'POST', 'P5平台复核退回REWORK'/) assert.doesNotMatch(source, /decision: 'APPROVE'/) assert.match(source, /manualCompletion: true/) assert.match(source, /P5返工重新确认箱标阶段/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 3f3bd98be..7afe31a09 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -47,7 +47,7 @@ function blocker(context, name, reason, response = null) { } async function request(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return { response, data: success(response) ? response.body?.data : null } } @@ -85,7 +85,7 @@ async function fundRelabelWallet(context, customerId) { async function expectConflict(context, token, method, name, path, body) { let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.requestOverseas(method, path, body, {}, token) + response = await context.request(method, path, body, token, {}) if (response?.http !== 0 || attempt === 3) break await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } @@ -99,8 +99,7 @@ async function resolvePallet(context, token, palletCode, name = '扫码解析托 } async function resolvePublicLocation(context, fixture, sourceLocationCode) { - const response = await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`) + const response = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`) const locations = records(response?.body?.data) const location = locations.find((item) => item?.locationType === 'SHARED_OVERFLOW' && item?.locationCode === 'A0' && item?.status === 'ACTIVE' && item.locationCode !== sourceLocationCode) @@ -389,10 +388,10 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const inventoryCandidates = [] let inventoryCandidatesResponse = null for (const inboundCode of inboundCodes) { - inventoryCandidatesResponse = await context.requestOverseas('GET', query( + inventoryCandidatesResponse = await context.request('GET', query( '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: fixture.overseasWarehouseId, inboundCode, - }), undefined, {}, merchantToken) + }), undefined, merchantToken, {}) context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse, success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS) if (!success(inventoryCandidatesResponse)) return false @@ -437,8 +436,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { } }), } - const createdResponse = await context.requestOverseas('POST', - query(relabelBase, { customerId }), createBody) + const createdResponse = await context.request('POST', query(relabelBase, { customerId }), createBody) const created = success(createdResponse) ? createdResponse.body?.data : null const relabelNo = id(created?.application?.relabelNo) context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse, @@ -449,11 +447,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) { blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse) return false } - const reviewed = await request(context, context.overseasToken, 'PUT', 'P5尾程订单审核直接贴标申请', + const reviewed = await request(context, context.token, 'PUT', 'P5尾程订单审核直接贴标申请', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.application.lastlegOrderId))}/relabel-review`, { customerId }), { status: 'PENDING_PROCESSING', remark: context.marker }) if (!success(reviewed.response) || reviewed.data?.application?.status !== 'PENDING_PROCESSING') return false - const funded = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), + const funded = await poll(async () => context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), (response) => success(response) && response.body?.data?.application?.walletStatus === 'FROZEN') context.addStep(MODULE, 'P5换标费用冻结后开放PDA作业', funded.result, funded.result?.body?.data?.application?.walletStatus === 'FROZEN', @@ -537,7 +535,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { detail = exception.data if (!detail) return false - const photo = await context.requestOverseasMultipart('POST', + const photo = await context.requestMultipart('POST', `${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, { name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG, }, token) @@ -557,9 +555,9 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const reviewPath = `${relabelBase}/${encodeURIComponent(relabelNo)}/rework` const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data.version), idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` } - const reworked = await request(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody) + const reworked = await request(context, context.token, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody) if (!success(reworked.response)) return false - const reworkReplay = await request(context, context.overseasToken, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) + const reworkReplay = await request(context, context.token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) context.assert(MODULE, 'P5 REWORK重放保持AVAILABLE等待重新认领', reworkReplay.response, reworkReplay.data?.taskStatus === 'AVAILABLE' && Number(reworkReplay.data.relabelledPackages) === 0, trace(reworkReplay.response), OPTIONS) @@ -588,7 +586,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { if (!success(redo.response)) return false detail = redo.data } - const bindingResponse = await context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`) + const bindingResponse = await context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`) const bindingDetail = success(bindingResponse) ? bindingResponse.body.data : null if (!Array.isArray(bindingDetail?.items) || bindingDetail.items.length !== 3) return false @@ -681,7 +679,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { `targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS) for (const fact of movedFacts) { - const positionsResponse = await context.requestOverseas('GET', query( + const positionsResponse = await context.request('GET', query( `/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`, { customerId }), undefined) const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data) @@ -694,7 +692,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { && targetPosition?.inboundCode === fact.inboundCode, `targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS) } - const ledgerResponse = await context.requestOverseas('GET', query( + const ledgerResponse = await context.request('GET', query( '/api/platform/v1/last-leg/inventory/ledger', { customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE', occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19), @@ -721,8 +719,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { trace(finalReplay.response), OPTIONS) const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), remark: `${context.marker}-customer-confirmed` } - const confirmedResponse = await context.requestOverseas('POST', - `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken) + const confirmedResponse = await context.request('POST', `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, merchantToken, {}) const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse, confirmed?.application?.status === 'READY_FOR_OUTBOUND' @@ -741,13 +738,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) { export async function settleRelabelFulfillment(context, relabel) { const relabelNo = relabel.relabelNo const relabelBase = '/api/platform/v1/last-leg/relabel-applications' - const terminal = await context.requestOverseas('PUT', - `${relabelBase}/${encodeURIComponent(relabelNo)}/status`, { + const terminal = await context.request('PUT', `${relabelBase}/${encodeURIComponent(relabelNo)}/status`, { status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`, }) context.addStep(MODULE, 'P5待出库换标单完成履约', terminal, success(terminal) && terminal.body?.data?.application?.status === 'COMPLETED', trace(terminal), OPTIONS) - const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), + const settled = await poll(async () => context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), (response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED') context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result, settled.result?.body?.data?.application?.walletStatus === 'SETTLED', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 3702cf39e..146c65592 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -59,20 +59,19 @@ function blockerGap(context, name, reason, category = 'fixture-unavailable', res } async function pda(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return { response, data: success(response) ? response.body?.data : null } } async function expected(context, token, method, name, path, body, predicate) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS) return response } async function captureCompletedOrder(context, containerNo, expectedCustomerId) { - const response = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) const candidates = records(response?.body?.data) .filter((item) => id(item?.containerNo) === id(containerNo)) const exactCustomer = expectedCustomerId == null ? candidates @@ -85,7 +84,7 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) { context.createdResources.phase4OverseasPda.orderId = id(order.id) context.createdResources.phase4OverseasPda.customerId = id(order.customerId) const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}` - const detail = await context.requestOverseas('GET', detailPath) + const detail = await context.request('GET', detailPath) context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail, success(detail) && id(detail.body?.data?.order?.id) === id(order.id) && Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0, @@ -102,10 +101,10 @@ async function loginMerchantForNegativeProbe(context) { if (!username || !password) return null const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const data = success(response) ? response.body?.data : null - context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response, - success(response) && nonBlank(data?.overseasAccessToken), - `overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) - return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null + context.addStep(MODULE, '本轮客户登录取得访问令牌用于PDA越权反证', response, + success(response) && nonBlank(data?.accessToken), + `tokenPresent=${nonBlank(data?.accessToken)}; ${trace(response)}`, OPTIONS) + return nonBlank(data?.accessToken) ? data.accessToken : null } export async function resolvePickerAccess(context) { @@ -144,17 +143,16 @@ export async function createPicker(context, index, overseasWarehouseId, access) `membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || 'missing'}`, OPTIONS) if (!longId(membershipId)) return null const login = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') - const token = success(login) ? login.body?.data?.overseasAccessToken : null - context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token), - `overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) + const token = success(login) ? login.body?.data?.accessToken : null + context.addStep(MODULE, `海外PDA操作员${index}登录取得统一访问令牌`, login, nonBlank(token), + `tokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) if (!nonBlank(token)) return { membershipId, username, token: null } let projectionResponse = null let projectionReady = false let projectionAttempt = 0 for (; projectionAttempt < 30; projectionAttempt += 1) { - projectionResponse = await context.requestOverseas('GET', - '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + projectionResponse = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) projectionReady = success(projectionResponse) || notFound(projectionResponse) if (projectionReady || !forbidden(projectionResponse)) break await new Promise((resolve) => setTimeout(resolve, 500)) @@ -193,12 +191,10 @@ async function resolveGeneratedPallet(context, inbound) { const pallet = candidates[0] if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId) - const listed = await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`) + const listed = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`) const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode) const locations = stored && longId(stored?.locationId) - ? await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`) + ? await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`) : null const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId)) const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode) @@ -263,14 +259,14 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti } export async function uploadPhotos(context, token) { - const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + const malicious = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('')), }, token) context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious, malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS) const photos = [] for (const role of PHOTO_ROLES) { - const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + const response = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, }, token) const fileId = id(response?.body?.data?.fileId) @@ -290,8 +286,7 @@ async function awaitProducerConsumption(context, producers) { for (let attempt = 1; attempt <= 90; attempt += 1) { const missing = expected.filter((containerNo) => !consumed.has(containerNo)) const results = await Promise.all(missing.map(async (containerNo) => { - const response = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) return { containerNo, response } })) for (const { containerNo, response } of results) { @@ -316,9 +311,7 @@ async function awaitProducerConsumption(context, producers) { async function convertReceivingContainers(context, producers) { for (const producer of producers) { const containerNo = id(producer?.containerNo) - const summaryResponse = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, - undefined, {}, context.overseasToken) + const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {}) const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records) ? summaryResponse.body.data.records : [] const summary = summaries.find((item) => id(item?.containerNo) === containerNo) @@ -336,9 +329,7 @@ async function convertReceivingContainers(context, producers) { `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS) if (!convertible) return false const bookingId = id(summary.sourceContainerBookingId) - const arrivalQueryResponse = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/orders/container-arrival-times/query', - { sourceContainerBookingIds: [bookingId] }, {}, context.overseasToken) + const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {}) const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data) ? arrivalQueryResponse.body.data : [] let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId) @@ -349,12 +340,8 @@ async function convertReceivingContainers(context, producers) { if (!arrivalTime) return false if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) { const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19) - const arrivalUpdateResponse = await context.requestOverseas('PUT', - `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, - { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version), - remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, - { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) }, - context.overseasToken) + const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version), + remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, context.token, { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) }) arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse, id(arrivalTime?.sourceContainerBookingId) === bookingId @@ -364,8 +351,7 @@ async function convertReceivingContainers(context, producers) { if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false } const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}` - const previewResponse = await context.requestOverseas('GET', previewPath, - undefined, {}, context.overseasToken) + const previewResponse = await context.request('GET', previewPath, undefined, context.token, {}) const preview = success(previewResponse) ? previewResponse.body?.data : null context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse, preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders @@ -377,9 +363,7 @@ async function convertReceivingContainers(context, producers) { const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) } const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128) - const convertedResponse = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/inbound-plans/convert-container', body, - { 'Idempotency-Key': key }, context.overseasToken) + const convertedResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key }) const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data) ? convertedResponse.body.data : [] const convertedInboundCodes = new Set(plans @@ -412,9 +396,7 @@ async function convertReceivingContainers(context, producers) { OPTIONS) } rememberGeneratedPlans(context, plans) - const replayResponse = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/inbound-plans/convert-container', body, - { 'Idempotency-Key': key }, context.overseasToken) + const replayResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key }) context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse, success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans), trace(replayResponse), OPTIONS) @@ -428,9 +410,7 @@ async function convertReceivingContainers(context, producers) { `plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`, OPTIONS) } - const progressResponse = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, - undefined, {}, context.overseasToken) + const progressResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, undefined, context.token, {}) const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse, progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders @@ -452,8 +432,7 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer let response = null let candidatesByContainer = new Map() for (let attempt = 1; attempt <= 30; attempt += 1) { - response = await context.requestOverseas('GET', - '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {}) const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] candidatesByContainer = new Map(candidateRows .map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo))) @@ -499,8 +478,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa } const cartonFixtures = [] for (const [index, cartonMark] of cartonCandidates.entries()) { - const response = await context.requestOverseas('POST', - '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) + const response = await context.request('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, token, {}) if (!strictDropship && overseasCartons.includes(cartonMark) && a0404Message(response, /^Carton mark was not found$/)) { context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true, @@ -669,15 +647,15 @@ async function receiveDropship(context, token, producer, inbounds) { } async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { - if (runAuthorizationProbes && context.overseasToken) { - const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) - context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + if (runAuthorizationProbes && context.token) { + const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {}) + context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } if (runAuthorizationProbes && merchantToken) { - const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) - context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, merchantToken, {}) + context.addStep(MODULE, '客户访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } - const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const before = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) const noCurrent = notFound(before) context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before), `${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS) @@ -848,7 +826,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) - const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index 36eecc1db..776c34df1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -42,7 +42,7 @@ function add(context, name, response, passed, evidence = '') { } async function overseas(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) add(context, name, response, success(response)) return { response, data: success(response) ? response.body?.data : null } } @@ -70,7 +70,7 @@ async function loginMerchant(context) { const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password, }, '') - const token = success(response) ? response.body?.data?.overseasAccessToken : null + const token = success(response) ? response.body?.data?.accessToken : null add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) return nonBlank(token) ? token : null } @@ -136,7 +136,7 @@ export async function run(context) { const form = new FormData() form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)], { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) - const uploaded = await context.requestOverseasMultipart('POST', + const uploaded = await context.requestMultipart('POST', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, form, merchantToken) add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) @@ -150,7 +150,7 @@ export async function run(context) { } } - const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', + const outboundCandidates = await overseas(context, context.token, 'GET', '平台查询本轮退货出库候选', query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { customerId, warehouseId: warehouse.id, })) @@ -165,7 +165,7 @@ export async function run(context) { returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker, idempotencyKey: key(context, 'OUTBOUND'), } - const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单', + const outbound = await overseas(context, context.token, 'POST', '平台创建独立退货出库单', query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody) const taskNo = id(outbound.data?.taskNo) const outboundNo = id(outbound.data?.outboundNo) @@ -187,7 +187,7 @@ export async function run(context) { const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) - const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态', + const outboundDetail = await overseas(context, context.token, 'GET', '平台回查退货出库最终状态', query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, completed.data?.status === 'COMPLETED' @@ -220,7 +220,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, context.assert(MODULE, name, null, Boolean(value), `relabelNo=${relabelNo}`, OPTIONS) if (!value) throw new Error(name) } - const candidates = await command(context.overseasToken, 'GET', '查询换标完成后的退货出库候选', + const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选', query(`${root}/return/candidates`, { customerId, warehouseId })) const selected = [] for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) { @@ -236,13 +236,13 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, const mark = selected[0].cartonMark const body = { recipient: `${context.marker} RELABELLED RETURN`, overseasWarehouseId: warehouseId, returnNo, cartonMarks: [mark], remark: context.marker, idempotencyKey: key(context, 'RELABELLED-OUTBOUND') } - const task = await command(context.overseasToken, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body) - const replay = await command(context.overseasToken, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body) + const task = await command(context.token, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body) + const replay = await command(context.token, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body) require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo) && replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo) const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}` const detail = await command(pdaToken, 'GET', 'PDA核对已换标库存出库任务', path) - const frozen = await command(context.overseasToken, 'GET', '平台核对本轮冻结出库箱号', + const frozen = await command(context.token, 'GET', '平台核对本轮冻结出库箱号', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) const items = detail.items || [] require('出库任务只包含本轮已换标箱', detail.task?.outboundNo === task.outboundNo @@ -255,7 +255,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, cartonMarks: [mark], idempotencyKey: key(context, 'RELABELLED-PICK'), }) const completed = await command(pdaToken, 'POST', 'PDA完成已换标库存出库', `${path}/complete`, { idempotencyKey: key(context, 'RELABELLED-COMPLETE') }) - const outbound = await command(context.overseasToken, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) + const outbound = await command(context.token, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) const after = await command(merchantToken, 'GET', '客户回查已换标库存出库终态', `${inventory}/${encodeURIComponent(mark)}`) require('已换标库存出库完成且来源和换标关联不漂移', completed.status === 'COMPLETED' && outbound.outbound?.status === 'OUTBOUNDED' && after.status === 'OUTBOUNDED' @@ -277,7 +277,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS) if (!passed) throw new Error(name) } - const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`, + const reviewed = await command(context.token, 'PUT', '审核本轮退货换标申请', `${platform}/status`, { status: 'PENDING_PROCESSING', remark: context.marker }) require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING') let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') }) @@ -311,7 +311,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work) const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png') - const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken) + const photo = await context.requestMultipart('POST', `${work}/photos/files`, form, pdaToken) add(context, '上传合成换标完工照片', photo, success(photo)) if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败') task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index e6a42f051..ba64512e8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -10,9 +10,9 @@ const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.ur test('relabelled outbound refuses another relabel application before creating an outbound', async () => { const calls = [] - const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, + const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, addStep() {}, assert() {}, - async requestOverseas(method, path) { + async request(method,path) { calls.push({ method, path }) return { http: 200, body: { code: '200', data: path.includes('/candidates') ? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } } @@ -22,10 +22,10 @@ test('relabelled outbound refuses another relabel application before creating an }) test('return outbound binds platform frozen carton to PDA item without requiring ordinary inventory positions', async () => { - const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, + const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, - async requestOverseas(method, path, body) { + async request(method,path,body) { let data if (path.includes('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', @@ -47,9 +47,9 @@ test('return outbound binds platform frozen carton to PDA item without requiring test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => { const calls = [] const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] } - const context = { marker: 'fixture', overseasToken: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, + const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, - async requestOverseas(method, path, body, _headers, token) { + async request(method,path,body,token,_headers) { calls.push({ method, path, body, token }) let data = task if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } } @@ -68,7 +68,7 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } } return { http: 200, body: { code: '200', data } } }, - async requestOverseasMultipart(method, path, form, token) { + async requestMultipart(method, path, form, token) { assert.equal(token, 'pda'); assert.ok(form instanceof FormData) calls.push({ method, path }) return { http: 200, body: { code: '200', data: { fileId: '7' } } } @@ -101,7 +101,7 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt assert.match(source, /palletCode: cartonA/) assert.match(source, /最后一批缺少打托照片拒绝且库存不变/) assert.match(source, /photos\/files/) - assert.match(source, /requestOverseasMultipart\('POST',[^\n]+photoForm, picker\.token\)/) + assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/) assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/) assert.match(source, /不存在SKU不是业务错误/) assert.match(source, /已完成退货单不再进入待签收流程/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 3fbdfa1a1..016f68832 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -55,10 +55,10 @@ function add(context, name, response, passed, evidence = '') { } async function overseas(context, token, method, name, path, body = undefined) { - let response = await context.requestOverseas(method, path, body, {}, token) + let response = await context.request(method, path, body, token, {}) for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) - response = await context.requestOverseas(method, path, body, {}, token) + response = await context.request(method, path, body, token, {}) } add(context, name, response, success(response)) return { response, data: success(response) ? response.body?.data : null } @@ -87,7 +87,7 @@ async function loginMerchant(context) { const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password, }, '') - const token = success(response) ? response.body?.data?.overseasAccessToken : null + const token = success(response) ? response.body?.data?.accessToken : null add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) return nonBlank(token) ? token : null } @@ -107,8 +107,7 @@ export async function approveReturnFixture(context, merchantToken, created) { if (!success(owned.response) || owned.data?.order?.returnNo !== order.returnNo || owned.data?.order?.status !== 'PENDING_REVIEW') throw new Error('客户不可读取当前待审核退货单') const path = `/api/platform/v1/last-leg/return-orders/${encodeURIComponent(order.returnNo)}/review` - const response = await context.requestOverseas('POST', path, - { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, { 'X-Customer-Id': expectedCustomer }) + const response = await context.request('POST', path, { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, undefined, { 'X-Customer-Id': expectedCustomer }) add(context, '平台审核本轮退货预报', response, success(response)) if (!success(response)) throw new Error(`退货审核失败:${trace(response)}`) const detail = await overseas(context, merchantToken, 'GET', '客户回查退货审核状态', @@ -193,8 +192,7 @@ export async function run(context) { const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody) context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response, id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS) - await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS, - { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)) + await reject('退货预报同键异载荷返回冲突', conflict, () => context.request('POST', RETURN_ORDERS, { ...createBody, remark: `${context.marker}-CHANGED` }, merchantToken, {})) await approveReturnFixture(context, merchantToken, created.data) @@ -205,8 +203,7 @@ export async function run(context) { ['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]], ['重复预报SKU', [createBody.items[0], createBody.items[0]]], ]) { - await reject(`拒绝${name}`, validationFailure, () => context.requestOverseas('POST', RETURN_ORDERS, - { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, {}, merchantToken)) + await reject(`拒绝${name}`, validationFailure, () => context.request('POST', RETURN_ORDERS, { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, merchantToken, {})) } const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量', query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA })) @@ -214,8 +211,7 @@ export async function run(context) { Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo && records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS) - await reject('不存在退货单返回可诊断未找到', notFound, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, - { returnNo: `MISSING-${suffix}` }, {}, picker.token)) + await reject('不存在退货单返回可诊断未找到', notFound, () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo: `MISSING-${suffix}` }, picker.token, {})) const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` }) @@ -260,9 +256,9 @@ export async function run(context) { && id(firstReplay.data?.receiptBatchNo) === firstBatchNo && id(firstReplay.data?.palletCode) === cartonA, trace(firstReplay.response), OPTIONS) - await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { + await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, - }, {}, picker.token)) + }, picker.token, {})) await reject('超收被拒绝且不得生成费用批次', conflict, async () => { const body = { @@ -271,7 +267,7 @@ export async function run(context) { } let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, body, {}, picker.token) + response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, body, picker.token, {}) if (response?.http !== 0 || attempt === 3) break await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } @@ -285,13 +281,12 @@ export async function run(context) { ['非法签收方式', { startedBy: 'INVALID' }, validationFailure], ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict], ]) { - await reject(`拒绝${name}`, expected, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, - { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, {}, picker.token)) + await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {})) } - await reject('部分签收退货单禁止合箱', conflict, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { + await reject('部分签收退货单禁止合箱', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10, weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'), - }, {}, picker.token)) + }, picker.token, {})) const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) @@ -310,7 +305,7 @@ export async function run(context) { await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => { let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token) + response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, secondBody, picker.token, {}) if (response?.http !== 0 || attempt === 3) break await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } @@ -319,7 +314,7 @@ export async function run(context) { const photoForm = new FormData() // Synthetic HTTP fixture, not a photograph of real warehouse activity. photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png') - const photo = await context.requestOverseasMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) + const photo = await context.requestMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) add(context, '上传本轮合成打托测试照片', photo, success(photo)) if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败') const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) @@ -338,7 +333,7 @@ export async function run(context) { context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) await reject('已完成退货单不再进入待签收流程', notFound, - () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)) + () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo }, picker.token, {})) const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response, @@ -358,13 +353,11 @@ export async function run(context) { && detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo), `receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS) - const merchantInventory = await context.requestOverseas('GET', - query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), - undefined, {}, merchantToken) + const merchantInventory = await context.request('GET', query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), undefined, merchantToken, {}) add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory)) const customerId = id(context.context?.merchantQualificationFixture?.tenantId) - const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存', + const inventory = await overseas(context, context.token, 'GET', '平台按客户和退货单回查全部退货箱库存', query('/api/platform/v1/last-leg/return-inventory/cartons', { page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo, })) @@ -376,7 +369,7 @@ export async function run(context) { && sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo), `cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS) - const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存', + const summary = await overseas(context, context.token, 'GET', '平台回查退货SKU汇总库存', query('/api/platform/v1/last-leg/return-inventory', { page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, })) @@ -389,14 +382,14 @@ export async function run(context) { for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 3 }, { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 2 }].entries()) { - const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`, + const carton = await overseas(context, context.token, 'GET', `平台回查第${index + 1}个退货箱明细`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`) context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response, carton.data?.sourceBatchNo === expected.batchNo && Number(carton.data?.totalQuantity) === expected.quantity && Array.isArray(carton.data?.items) && carton.data.items.length > 0, `batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS) - const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`, + const label = await overseas(context, context.token, 'GET', `平台生成第${index + 1}个退货箱标签`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`) context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response, label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo @@ -454,9 +447,7 @@ export async function run(context) { context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response, success(mergeReplay.response) && nonBlank(merged.data?.cartonMark) && id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS) - await reject('合箱同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', - '/api/pda/v1/last-leg/return-inventory/cartons/merge', - { ...mergeBody, weight: 3 }, {}, picker.token)) + await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, picker.token, {})) const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) @@ -468,8 +459,7 @@ export async function run(context) { ['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict], ['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound], ]) { - await reject(`合箱拒绝${name}`, expected, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', - { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, {}, picker.token)) + await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {})) } await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求', '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index efb735e5e..ac32a2cbc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -48,8 +48,8 @@ async function merchantLogin(context, fixture) { const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const auth = success(response) ? response.body.data : null assert(context, '客户账号同时取得国内和海外短时Token', response, - Boolean(auth?.accessToken) && Boolean(auth?.overseasAccessToken), - `customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.overseasAccessToken)}`) + Boolean(auth?.accessToken) && Boolean(auth?.accessToken), + `customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`) return auth } @@ -96,7 +96,7 @@ async function prepareWallet(context, customerId) { return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null } -async function procurementFlow(context, fixture, overseasToken) { +async function procurementFlow(context, fixture, token) { const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements' const payload = { customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`, @@ -105,7 +105,7 @@ async function procurementFlow(context, fixture, overseasToken) { contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker, idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255), } - const created = await context.requestOverseas('POST', merchantPath, payload, {}, overseasToken) + const created = await context.request('POST', merchantPath, payload, token, {}) const detail = created?.body?.data const orderNo = detail?.summary?.orderNo assert(context, '客户创建钱包采购单', created, @@ -117,7 +117,7 @@ async function procurementFlow(context, fixture, overseasToken) { `orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`) const customerId = id(fixture.tenantId) - const approved = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), { + const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), { version: Number(detail.summary.version), decision: 'APPROVE', idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker, }) @@ -128,7 +128,7 @@ async function procurementFlow(context, fixture, overseasToken) { const files = [] for (const category of ['VOUCHER', 'RECEIPT']) { - const uploaded = await context.requestOverseasMultipart('POST', + const uploaded = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }), { content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` }) assert(context, `采购单上传${category}文件`, uploaded, @@ -138,7 +138,7 @@ async function procurementFlow(context, fixture, overseasToken) { } if (files.length !== 2) return null - const fee = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), { + const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), { feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01, voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker, }) @@ -149,7 +149,7 @@ async function procurementFlow(context, fixture, overseasToken) { if (!success(fee) || !feeNo) return null const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId }) - const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + const frozen = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN') const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) assert(context, '采购费用跨区冻结到账', frozen.result, @@ -157,13 +157,13 @@ async function procurementFlow(context, fixture, overseasToken) { if (frozenFee?.walletStatus !== 'FROZEN') return null const current = frozen.result.body.data.summary - const settledOrder = await context.requestOverseas('PATCH', detailPath, { + const settledOrder = await context.request('PATCH', detailPath, { version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity), }) assert(context, '采购全量入库推进待出库并触发结算', settledOrder, success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND', `status=${settledOrder.body?.data?.summary?.status || 'missing'}`) - const settled = await poll(async () => context.requestOverseas('GET', detailPath), + const settled = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED') const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) assert(context, '采购费用跨区结算到账', settled.result, @@ -191,10 +191,9 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) { } } -async function relabelFlow(context, overseasToken, warehouseId, suffix, terminalStatus) { - const candidatesResponse = await context.requestOverseas('GET', query( - '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), - undefined, {}, overseasToken) +async function relabelFlow(context, token, warehouseId, suffix, terminalStatus) { + const candidatesResponse = await context.request('GET', query( + '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), undefined, token, {}) const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data) ? candidatesResponse.body.data : [] const currentPda = context.createdResources?.phase4OverseasPda @@ -229,7 +228,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal Boolean(source?.position), `warehouseId=${warehouseId}, candidates=${candidates.length}`) if (!source?.position) return null const payload = relabelPayload(context, warehouseId, source, suffix, 0.01) - const created = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, {}, overseasToken) + const created = await context.request('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, token, {}) const relabelNo = created?.body?.data?.application?.relabelNo assert(context, `${suffix}换标单创建并携带收费金额`, created, success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01, @@ -237,7 +236,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal if (!success(created) || !relabelNo) return null const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}` - const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + const frozen = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN') assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result, frozen.result?.body?.data?.application?.walletStatus === 'FROZEN', @@ -245,32 +244,29 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null const reviewStatus = terminalStatus === 'COMPLETED' ? 'PENDING_PROCESSING' : 'CANCELLED' - const review = await context.requestOverseas('PUT', query( + const review = await context.request('PUT', query( `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.body.data.application.lastlegOrderId))}/relabel-review`, - { customerId: id(context.context.merchantQualificationFixture.tenantId) }), - { status: reviewStatus, remark: context.marker }) + { customerId: id(context.context.merchantQualificationFixture.tenantId) }), { status: reviewStatus, remark: context.marker }) assert(context, `${suffix}尾程订单审核直接贴标申请`, review, success(review) && review.body.data?.application?.status === reviewStatus) if (!success(review)) return null if (terminalStatus === 'COMPLETED') { - const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) + const processing = await context.request('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) assert(context, '换标单冻结后才允许进入处理中', processing, success(processing) && processing.body.data?.application?.status === 'PROCESSING') - const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`, - { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker }) + const confirmPending = await context.request('PUT', `${detailPath}/status`, { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker }) assert(context, '换标处理完成后进入客户确认', confirmPending, success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING') - const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`, - { status: 'READY_FOR_OUTBOUND', remark: context.marker }) + const readyForOutbound = await context.request('PUT', `${detailPath}/status`, { status: 'READY_FOR_OUTBOUND', remark: context.marker }) assert(context, '换标客户确认后进入待出库', readyForOutbound, success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND') } - const terminal = terminalStatus === 'CANCELLED' ? review : await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) + const terminal = terminalStatus === 'CANCELLED' ? review : await context.request('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) assert(context, `${suffix}换标单进入${terminalStatus}`, terminal, success(terminal) && terminal.body.data?.application?.status === terminalStatus) const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED' - const wallet = await poll(async () => context.requestOverseas('GET', detailPath), + const wallet = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal) assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result, wallet.result?.body?.data?.application?.walletStatus === walletTerminal, @@ -308,14 +304,13 @@ async function verifyPalletSummaries(context, warehouseId) { success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount']) && Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount), `total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`) - const overseas = await context.requestOverseas('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20') + const overseas = await context.request('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20') const o = overseas?.body?.data assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas, success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']) && Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount), `total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`) - const warehouse = await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`) + const warehouse = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`) assert(context, '海外仓级托盘汇总可用', warehouse, success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])) } @@ -324,7 +319,7 @@ async function verifyDestuffCosts(context, completedFacts) { const evidence = [] for (const fact of completedFacts) { const businessNo = `DESTUFF-${fact.containerBookingId}` - const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { + const lastleg = await poll(async () => context.request('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { businessType: 'OVERSEAS_CONTAINER', businessNo, })), (response) => success(response) && rows(response.body.data).length > 0, 75) const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : [] @@ -363,9 +358,9 @@ export async function run(context) { const auth = await merchantLogin(context, fixture) const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null - const procurement = wallet ? await procurementFlow(context, fixture, auth.overseasAccessToken) : null - const settledRelabel = procurement ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'SETTLE', 'COMPLETED') : null - const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'RELEASE', 'CANCELLED') : null + const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null + const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null + const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null const relabels = [settledRelabel, releasedRelabel].filter(Boolean) const finalAccount = procurement && relabels.length === 2 ? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 8b889591a..05c8e17db 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -16,22 +16,15 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, - async request(method, requestPath, body) { - calls.push({ channel: 'domestic', method, path: requestPath, body }) - if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) - if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) - if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') - if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) - throw new Error(`Unexpected domestic path: ${requestPath}`) - }, - async requestOverseas(method, requestPath, body) { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,requestPath,body) { calls.push({ channel: 'overseas', method, path: requestPath, body }) - if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } - if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } - if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { + if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } + if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } + if (method === 'DELETE' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = null; return voidResponse() } + if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } + if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) { if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() } @@ -45,16 +38,27 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) - if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) - if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) - if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null + if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) return createdLocation == null ? response(undefined, 'A0404') : response(createdLocation) - if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) - if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) - if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) return response(createdPallet) throw new Error(`Unexpected path: ${requestPath}`) - }, - async requestOverseasBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } }, + } : async function(method,requestPath,body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) + if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`Unexpected domestic path: ${requestPath}`) + }; + return handler.apply(this,args); + }, + + async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } }, apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, @@ -65,10 +69,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.match(source, /export async function run\(context\)/) - assert.match(source, /context\.requestOverseas\('GET'/) - assert.match(source, /context\.requestOverseasBinary\('GET'/) + assert.match(source, /context\.request\('GET'/) + assert.match(source, /context\.requestBinary\('GET'/) assert.doesNotMatch(source, /https?:\/\//) - assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) + assert.doesNotMatch(source, /process\.env|fetch\(/) }) test('既有仓库场景只写本轮库位与托盘生命周期', () => { @@ -83,9 +87,9 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => { assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) - assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets'))) assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false) - assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/'))) + assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/'))) for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { const step = context.steps.find((item) => item.name === name) assert.ok(step?.passed, name) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 7328e5768..58430d4c6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -31,12 +31,12 @@ async function domesticGet(context, name, path) { return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } } async function overseasGet(context, name, path) { - const response = await context.requestOverseas('GET', path) + const response = await context.request('GET', path) assertEnvelope(context, name, response) return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } } async function overseasMutation(context, method, name, path, body) { - const response = await context.requestOverseas(method, path, body) + const response = await context.request(method, path, body) const passed = response?.http === 200 && String(response?.body?.code) === '200' context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) return { response, data: passed ? response.body?.data : null } @@ -48,7 +48,7 @@ async function domesticMutation(context, method, name, path, body) { return { response, data: passed ? response.body?.data : null } } async function overseasCsv(context, name, path) { - const response = await context.requestOverseasBinary('GET', path) + const response = await context.requestBinary('GET', path) context.addStep(MODULE, name, response, response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'), `bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS) @@ -164,7 +164,7 @@ export async function run(context) { const warehouseId = id(warehouse.id) context.createdResources.phase4OverseasWarehouse.customerId = customerId context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId - const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 })) + const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 })) assertPage(context, '公共库位列表', locations) const location = records(locations.data)[0] if (location) { @@ -172,12 +172,12 @@ export async function run(context) { longId(location.id) && id(location.warehouseId) === warehouseId && id(location.customerId) === '0' && nonBlank(location.locationCode) && nonBlank(location.status), `id=${id(location.id) || 'missing'}, customerId=${id(location.customerId) || 'missing'}, status=${location.status || 'missing'}; ${trace(locations.response)}`, OPTIONS) - const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`) + const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`) context.assert(MODULE, '公共库位详情对应列表记录', detail.response, id(detail.data?.id) === id(location.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0', `listId=${id(location.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } - const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 })) + const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 })) assertPage(context, '公共托盘列表', pallets) const pallet = records(pallets.data)[0] if (pallet) { @@ -185,7 +185,7 @@ export async function run(context) { longId(pallet.id) && id(pallet.warehouseId) === warehouseId && id(pallet.customerId) === '0' && nonBlank(pallet.palletCode) && ['IDLE', 'OCCUPIED', 'SCRAPPED'].includes(pallet.status), `id=${id(pallet.id) || 'missing'}, customerId=${id(pallet.customerId) || 'missing'}, status=${pallet.status || 'missing'}; ${trace(pallets.response)}`, OPTIONS) - const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`) + const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`) context.assert(MODULE, '公共托盘详情对应列表记录', detail.response, id(detail.data?.id) === id(pallet.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0', `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) @@ -209,7 +209,7 @@ async function verifyLocationLifecycle(context, warehouseId) { let locationId = '' try { const created = await overseasMutation(context, 'POST', '创建本轮公共库位', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, { locationCode, locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE', remark: context.marker, }) @@ -218,7 +218,7 @@ async function verifyLocationLifecycle(context, warehouseId) { context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId), `locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) if (!locationId) return - const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}` + const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}` const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath) context.assert(MODULE, '本轮公共库位创建事实正确', detail.response, id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0' @@ -237,9 +237,8 @@ async function verifyLocationLifecycle(context, warehouseId) { } finally { if (locationId) { const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) - const deletedDetail = await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + const deletedDetail = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail) context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted, @@ -287,7 +286,7 @@ async function verifyPalletLifecycle(context, warehouseId) { let palletId = '' try { const created = await overseasMutation(context, 'POST', '创建本轮公共托盘', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker, }) palletId = id(created.data) @@ -296,7 +295,7 @@ async function verifyPalletLifecycle(context, warehouseId) { `palletId=${palletId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) if (!palletId) return - const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}` + const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}` const detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath) context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response, id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId @@ -314,7 +313,7 @@ async function verifyPalletLifecycle(context, warehouseId) { id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5 && Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`, `capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) - const stale = await context.requestOverseas('PUT', detailPath, { + const stale = await context.request('PUT', detailPath, { palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE', remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version), }) @@ -323,17 +322,17 @@ async function verifyPalletLifecycle(context, warehouseId) { } finally { if (palletId) { const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, { + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, { palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED', remark: `${context.marker}-acceptance-retired`, version: Number((await overseasGet(context, '读取公共托盘报废前最新版本', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), }) context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired), `palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS) const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) context.assert(MODULE, '本轮公共托盘终态为已报废', retiredDetail.response, id(retiredDetail.data?.id) === palletId && retiredDetail.data?.status === 'SCRAPPED', `palletId=${id(retiredDetail.data?.id) || 'missing'}, status=${retiredDetail.data?.status || 'missing'}; ${trace(retiredDetail.response)}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 6225b8682..407f4d12c 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', ( ]) assert.ok(source.includes(evidence), `missing ${evidence}`) assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) assert.match(source, /creationMode === 'AUTO'/) - assert.match(source, /requestOverseasBinary\('GET'/) + assert.match(source, /requestBinary\('GET'/) assert.match(source, /palletMergeBatchId/) assert.match(source, /feeItemCode/) assert.match(source, /feeItemName/) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index cb53399f0..fb81380b1 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -26,7 +26,7 @@ function query(path, values) { return params.size ? `${path}?${params}` : path } async function api(context, method, name, path, body, headers = {}) { - const response = await context.requestOverseas(method, path, body, headers, context.overseasToken) + const response = await context.request(method, path, body, context.token, headers) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -123,12 +123,9 @@ async function verifyReceivingWarehouseChange(context, producer, details) { context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response, Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion) && id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS) - const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, - { 'Idempotency-Key': changeKey }, context.overseasToken) + const hashConflict = await context.request('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, context.token, { 'Idempotency-Key': changeKey }) context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) - const stale = await context.requestOverseas('PUT', changePath, - { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, - { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken) + const stale = await context.request('PUT', changePath, { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }) context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS) const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath, { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` }, @@ -210,7 +207,7 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { && row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01 && row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS) - const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { + const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, { customerId: id(representative.customerId), containerNo: producer.containerNo, })) const csv = new TextDecoder().decode(exported.bytes || new Uint8Array()) @@ -309,7 +306,7 @@ async function convertContainerPlans(context, producer, details) { for (const { plan, pallet } of pallets) { const line = plan.lines[0] - const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, { + const palletPath = query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, { page: 1, size: 10, keyword: pallet.palletNo, }) const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath) @@ -357,13 +354,11 @@ export async function verifyReceivedOrderHold(context) { const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) - const heldConflict = await context.requestOverseas('PUT', orderPath, - { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) + const heldConflict = await context.request('PUT', orderPath, { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, context.token, { 'Idempotency-Key': holdKey }) context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) - const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, - { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) + const blockedPlan = await context.request('POST', `${PLANS}/manual`, blockedPlanBody, context.token, { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }) context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) const releaseKey = `${context.marker}-tail-release`.slice(0, 128) @@ -412,10 +407,8 @@ export async function run(context) { refreshedDetails.push(refreshed.data.order) } const representative = refreshedDetails[0] - const rejectedHold = await context.requestOverseas('PUT', query( - `${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), - { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, - { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` }) + const rejectedHold = await context.request('PUT', query( + `${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, undefined, { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` }) context.addStep(MODULE, '入库前暂扣必须拒绝', rejectedHold, conflict(rejectedHold), trace(rejectedHold), OPTIONS) const afterHold = await detail(context, representative, '入库前暂扣拒绝后回查订单') const unchanged = success(afterHold.response) && JSON.stringify(afterHold.data?.order) === JSON.stringify(representative) @@ -440,9 +433,7 @@ export async function run(context) { { 'Idempotency-Key': nodesKey }) context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response, JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS) - const nodesConflict = await context.requestOverseas('PUT', nodesPath, - { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, - { 'Idempotency-Key': nodesKey }, context.overseasToken) + const nodesConflict = await context.request('PUT', nodesPath, { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, context.token, { 'Idempotency-Key': nodesKey }) context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS) const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath) context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response, diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 4d1b7b095..513e079e5 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -57,7 +57,7 @@ test('phase-five module proves the client delivery create, list and nested detai test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { for (const token of [ - '/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'", + '/documents/RECEIPT/files', 'requestMultipart', "category: 'RECEIPT'", 'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE', '/customer-visits/${encodeURIComponent(visitId)}', "visitResult: 'SATISFIED'", '/exception-orders', diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 7e0d38940..7005eaab7 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -33,17 +33,17 @@ function blocker(context, name, reason, response = null) { context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) } async function platform(context, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body) + const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } async function pda(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return { response, data: success(response) ? response.body?.data : null } } async function merchant(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -75,7 +75,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) if (!success(before.response) || !Number.isInteger(version) || version < 0) return null - const uploadResponse = await context.requestOverseasMultipart('POST', + const uploadResponse = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`), @@ -158,16 +158,16 @@ async function loginMerchant(context, customerId) { password: credentials.password, }, '') const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS) - context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), - `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) - if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.overseasAccessToken)) return null + context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken), + `tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS) + if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.accessToken)) return null const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken) const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse, trace(profileResponse), OPTIONS) context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse, id(profile?.id) === customerId, `merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS) - return id(profile?.id) === customerId ? auth.overseasAccessToken : null + return id(profile?.id) === customerId ? auth.accessToken : null } function fixture(context) { @@ -270,7 +270,7 @@ export async function run(context) { trace(updated.response), OPTIONS) if (!success(updated.response)) return - const bol = await context.requestOverseasBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) + const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) @@ -310,8 +310,7 @@ export async function run(context) { && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) const lockedItem = updated.data?.items?.[0] if (lockedItem?.id) { - const deniedRemove = await context.requestOverseas('DELETE', - `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) + const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, @@ -323,8 +322,7 @@ export async function run(context) { context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) - const transferConflict = await context.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), - { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken) + const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {}) context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo && transferConflict.body?.data?.replayed === true @@ -409,9 +407,7 @@ export async function run(context) { && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) - const removedComplete = await context.requestOverseas('POST', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), - { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) + const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, removedComplete?.http === 404 || removedComplete?.http === 405 || (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))), diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index 20dd73f1e..da1505fc2 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -138,7 +138,7 @@ test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async ( assert.match(source, /'X-Customer-Id': customerId/) assert.match(source, /status !== 'PENDING_REVIEW'/) assert.match(source, /palletCode: order\.palletCode/) - assert.match(source, /requestOverseasMultipart\('POST',[^\n]+form, operator\.token\)/) + assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/) assert.match(source, /两个合法SKU同批签收仍整笔拒绝/) assert.match(source, /palletCode: nextPallet\.palletCode/) assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 1654dfa06..514596acd 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -61,10 +61,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS) } async function request(token, method, path, body) { - let response = await context.requestOverseas(method, path, body, {}, token) + let response = await context.request(method, path, body, token, {}) for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) - response = await context.requestOverseas(method, path, body, {}, token) + response = await context.request(method, path, body, token, {}) } return response } @@ -87,8 +87,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) const customerId = customerByToken.get(token) if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符') - const reviewed = await context.requestOverseas('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, - { decision: 'APPROVE', remark: context.marker }, { 'X-Customer-Id': customerId }) + const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId }) check('边界退货单审核通过进入待签收', reviewed, ok(reviewed) && reviewed.body.data?.order?.status === 'PENDING_RECEIPT') if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败') const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker @@ -97,7 +96,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p if (!pallet.palletCode) throw new Error('退货托盘码缺失') const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png') - const photo = await context.requestOverseasMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) + const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId)) if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败') return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode } @@ -160,9 +159,9 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const fixtureB = secondary.context.merchantQualificationFixture resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '') - check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken)) - if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return - const tokenB = loginB.body.data.overseasAccessToken + check('第二隔离客户登录取得访问令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.accessToken)) + if (!ok(loginB) || !loginB.body?.data?.accessToken) return + const tokenB = loginB.body.data.accessToken customerByToken.set(tokenB, String(fixtureB.tenantId)) const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id)) @@ -364,7 +363,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await scenario('身份入口权限', async () => { const order = await newOrder('权限边界', `${prefix}-AUTH`, 1) - for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.overseasToken, [403]]]) { + for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.token, [403]]]) { await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses) } await receive(order) @@ -383,7 +382,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await scenario('签收流水与余额核对', async () => { const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3) const customerId = context.context.merchantQualificationFixture.tenantId - const ledger = () => good(context.overseasToken, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { + const ledger = () => good(context.token, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 })) check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), [])) const body = receiptBody(order, 2) diff --git a/scripts/admin-test-env-modules/return-review-contract.test.mjs b/scripts/admin-test-env-modules/return-review-contract.test.mjs index 8076f8576..595b87a04 100644 --- a/scripts/admin-test-env-modules/return-review-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-review-contract.test.mjs @@ -7,7 +7,7 @@ function fixture({ failedReview = false, wrongOwner = false, stale = false } = { const calls = [] const context = { marker: 'FIXTURE', context: { merchantQualificationFixture: { tenantId: '123' } }, addStep() {}, assert() {}, - async requestOverseas(method, path, body, headers, token) { + async request(method,path,body,token,headers) { calls.push({ method, path, body, headers, token }) if (method === 'POST') { assert.equal(headers['X-Customer-Id'], '123') diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 9b2ad4273..45add7123 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -147,13 +147,12 @@ export async function run(context) { const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) if (context.options.modules.includes('phase4-cross-region-events')) { - const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || '' - context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse, + const restrictedOverseasToken = resetLoginResponse.body?.data?.accessToken || '' + context.assert(module, '受限角色登录同时签发统一Token', resetLoginResponse, Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`, { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) if (restrictedOverseasToken) { - const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', - undefined, {}, restrictedOverseasToken) + const denied = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, restrictedOverseasToken, {}) context.assert(module, '缺少canonical海外订单权限必须403', denied, (denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code))) && Boolean(denied.requestId) && Boolean(denied.traceId), diff --git a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs index 56515796a..ecd20ccfd 100644 --- a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs @@ -1,110 +1,55 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' - -import { - EVENT_BRIDGE_RECOVERY_FIXTURE, - INTERNAL_TOKEN_COHORT_SERVICES, - recoveredEventBridgeFacts, - TEST_EVENT_BRIDGE_URLS, - validInternalTokenCohort, - validTestEventBridgeEnvironment, -} from './event-bridge-pending-recovery.mjs' - -const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') -const cnCompose = read('../../deploy/docker-compose.yml') -const overseasCompose = read('../../deploy/docker-compose.overseas.yml') -const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh') -const deployServices = read('../../deploy/scripts/deploy-services.sh') -const crossRegionHttp = read('phase4-cross-region-events.mjs') - -test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => { - assert.deepEqual(TEST_EVENT_BRIDGE_URLS, { - LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', - FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', - }) - assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true) - assert.equal(validTestEventBridgeEnvironment({ - ...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313', - }), false) +import { TEST_DIRECT_DELIVERY_URLS, INTERNAL_TOKEN_COHORT_SERVICES, validDirectDeliveryEnvironment, + validInternalTokenCohort, validDirectDeliveryEvidence } from './event-bridge-pending-recovery.mjs' +const read=p=>readFileSync(new URL(p,import.meta.url),'utf8') +const compose=read('../../deploy/docker-compose.yml') +test('all three direct targets use Compose DNS and current internal ports',()=>{ + assert.equal(validDirectDeliveryEnvironment(TEST_DIRECT_DELIVERY_URLS),true) + for(const [key,url] of Object.entries(TEST_DIRECT_DELIVERY_URLS)){ + assert.ok(compose.includes(`${key}: ${url}`)) + assert.equal(validDirectDeliveryEnvironment({...TEST_DIRECT_DELIVERY_URLS,[key]:'http://localhost:9312'}),false) + } + assert.doesNotMatch(compose,/EVENT_BRIDGE_URL|event-bridge-service:/) +}) +test('the three producers and consumers share internal token revision without exposing secrets',()=>{ + assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES,['firstleg','lastleg-service','finance']) + assert.match(compose,/INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) + assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r1'}),true) + assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r2'}),false) + assert.equal(validInternalTokenCohort({}),false) +}) +const attempt=(extra={})=>({eventId:'event-fixture',payloadHash:'hash-fixture',at:'2026-09-29T00:00:00Z',requestId:'request-fixture',traceId:'trace-fixture',...extra}) +const facts=(attempts,extra={})=>({marker:'run-fixture',eventId:'event-fixture',payloadHash:'hash-fixture',maxAttempts:12,sqlMutationCount:0,status:'SENT',inboxEventCount:1,businessEffectCount:1,attempts,...extra}) +test('DNS, invalid token and timeout recovery require original event identity and actual duplicate ACK',()=>{ + for(const failure of ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT']){ + const evidence=facts([attempt({failure}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})],{authorizedDeadRetry:failure==='TOKEN_REJECTED'}) + assert.equal(validDirectDeliveryEvidence(evidence),true) + assert.equal(validDirectDeliveryEvidence({...evidence,businessEffectCount:2}),false) + assert.equal(validDirectDeliveryEvidence({...evidence,sqlMutationCount:1}),false) + assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({eventId:'new-id',outcome:'DUPLICATE'})]}),false) + } + assert.equal(validDirectDeliveryEvidence(facts([attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'APPLIED',ackLost:true})])),false) +}) +test('retry budget exhaustion stays DEAD and never schedules an automatic retry',()=>{ + const evidence=facts(Array.from({length:12},()=>attempt({failure:'TIMEOUT'})),{status:'DEAD',automaticRetryScheduled:false}) + assert.equal(validDirectDeliveryEvidence(evidence),true) + assert.equal(validDirectDeliveryEvidence({...evidence,automaticRetryScheduled:true}),false) + assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({failure:'TIMEOUT'})]}),false) +}) +test('GET stability does not claim real event replay coverage',()=>{ + const source=read('./phase4-cross-region-events.mjs') + assert.match(source,/非事件重放证据/) + assert.match(source,/missing-direct-delivery-replay-evidence/) + assert.match(source,/event-outbox-operations\.mjs/) + assert.doesNotMatch(source,/经Bridge|不能访问.*Audience/) }) -test('Compose wires both producers to the stable service aliases used by test env', () => { - assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/) - assert.match(overseasCompose, - /FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/) - assert.match(cnCompose, - /FINANCE_FEE_EVENT_CONSUMER_URL:\s*\$\{FINANCE_FEE_EVENT_CONSUMER_URL:-http:\/\/finance:9203\/internal\/finance\/v1\/business-fee-source-events\}/) - assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/) - for (const source of [cnCompose, overseasCompose]) { - assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/) - assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/) - } -}) - -test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => { - assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [ - 'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service', - ]) - assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(overseasCompose, - /x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/) - assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/) - assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/) - assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/) - for (const source of [deployBuilt, deployServices]) { - assert.match(source, /--env-file\s+"\$ENV_FILE"/) - } - assert.equal(validInternalTokenCohort({ - firstleg: 'revision-20260827', - 'event-bridge-cn': 'revision-20260827', - 'event-bridge-overseas': 'revision-20260827', - 'lastleg-service': 'revision-20260827', - }), true) - assert.equal(validInternalTokenCohort({ - firstleg: 'revision-20260827', - 'event-bridge-cn': 'revision-20260827', - 'event-bridge-overseas': 'stale-revision', - 'lastleg-service': 'revision-20260827', - }), false) -}) - -test('incident fixture records DNS recovery followed by retry81 token rejection', () => { - assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, { - customerCode: 'C10000000', - eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', - initialOutboxStatus: 'PENDING', - initialRetryCount: 62, - failedTarget: '172.31.198.8:9313', - currentOutboxStatus: 'PENDING', - currentRetryCount: 81, - currentFailureCode: 'A0401', - currentFailureMessage: '内部服务令牌无效', - currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', - currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', - dockerDnsProven: true, - }) -}) - -test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => { - assert.equal(recoveredEventBridgeFacts({ - firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, - lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, - manualReplayCount: 0, sqlMutationCount: 0, - }), true) - assert.equal(recoveredEventBridgeFacts({ - firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, - lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0, - manualReplayCount: 0, sqlMutationCount: 0, - }), false) - assert.equal(recoveredEventBridgeFacts({ - firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, - lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, - manualReplayCount: 1, sqlMutationCount: 0, - }), false) - assert.match(crossRegionHttp, /const delivered = await poll/) - assert.match(crossRegionHttp, /deliveredRows\.length > 0/) - assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/) +test('permanent token failure stops immediately and recovery needs authorized DEAD retry', () => { + const stopped=facts([attempt({failure:'TOKEN_REJECTED'})],{status:'DEAD',automaticRetryScheduled:false}) + assert.equal(validDirectDeliveryEvidence(stopped),true) + const recovered=facts([attempt({failure:'TOKEN_REJECTED'}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})]) + assert.equal(validDirectDeliveryEvidence(recovered),false) + assert.equal(validDirectDeliveryEvidence({...recovered,authorizedDeadRetry:true}),true) }) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index a78f190b8..0757cdc17 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -92,7 +92,16 @@ test('same event replay adds nothing and preserves the allocated number', () => const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) - assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/) + const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox=')) + assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) + assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/) + assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/) + assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/) + const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope')) + assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) + assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/) + assert.match(racedBranch, /DeliveryAck\.Outcome\.DUPLICATE/) + assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/) assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/) }) diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs index f86c2fa1c..bfa891e43 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -16,7 +16,7 @@ export async function verifyWarehouseTransferReview(context, token, customerId, const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}` const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}` async function call(method, path, body, merchantToken) { - return context.requestOverseas(method, path, body, {}, merchantToken) + return context.request(method, path, body, merchantToken, {}) } const initial = await call('GET', scoped(transferNo)) context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial, diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index 0fa1ee893..ff1d2e60a 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -38,10 +38,10 @@ $requiredTests = @( "warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java", "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java" ) -$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway,file,warehouse", "-am", "test") +$mavenArgs = @("-f", $pom, "test") Write-Host "Backend regression root: $repoRoot" -Write-Host "Scope: order + firstleg + iam + gateway + file + warehouse Maven reactor (includes all module tests)" +Write-Host "Scope: complete root POM reactor (all active modules and their tests)" Write-Host "Database/API access: none" if (-not (Test-Path -LiteralPath $pom)) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index ad6943a31..b028d889c 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -18,6 +18,7 @@ import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' const args = parseArgs(process.argv.slice(2)) +if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options') const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME ?? await promptText('平台审核账号', '') diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 74939ae8a..7f7ffb1f0 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -20,8 +20,12 @@ test('frontend full regression includes domestic and overseas PDA node tests by assert.match(frontendScript, /tests\/\*\.test\.mjs/) }) -test('backend full regression includes file reactor and critical file/container coverage', () => { - assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/) +test('backend full regression uses the entire root reactor and preserves critical file/container coverage', () => { + const args = backendScript.match(/\$mavenArgs\s*=\s*@\(([^\r\n]+)\)/)?.[1] + assert.equal(args, '"-f", $pom, "test"') + assert.match(backendScript, /\$pom = Join-Path \$repoRoot "pom.xml"/) + assert.doesNotMatch(args, /-pl|-rf|-N|skipTests|maven.test.skip/) + assert.match(backendScript, /complete root POM reactor/) for (const required of [ 'file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java', 'file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java', @@ -36,6 +40,7 @@ test('backend full regression includes file reactor and critical file/container test('backend full regression rejects Mapper registration drift before Maven', () => { assert.match(backendScript, /scripts\/mapper-scan-contract\.mjs/) + assert.match(backendScript, /& node --test[^\r\n]*gateway-route-contract\.test\.mjs[\s\S]*& node \$mapperScanContract/) assert.match(backendScript, /& node \$mapperScanContract[\s\S]*& \$MavenCommand @mavenArgs/) }) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index c21e5a98f..fdb4551bc 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -45,28 +45,15 @@ test('overseas inbound commands, client services and dispatch follow completed P } }) -test('entry accepts a separate overseas Gateway without exposing token state', () => { - const options = parseArguments([ - '--gateway', 'http://localhost:9000/', - '--overseas-gateway', 'http://localhost:9300/', - '--modules', 'phase4-overseas-warehouse', - ]) +test('entry uses one TEST gateway and rejects retired regional options', () => { + const options = parseArguments(['--gateway', 'http://localhost:9000/', '--modules', 'phase4-overseas-warehouse']) assert.equal(options.gateway, 'http://localhost:9000') - assert.equal(options.overseasGateway, 'http://localhost:9300') - assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) - assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/) - assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/) - assert.match(source, /async refreshSession\(reason\)/) - assert.match(source, /refreshToken: this\.refreshToken/) - assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/) - assert.match(source, /module === 'system' && laterOverseasModule/) - assert.match(source, /system后新admin海外Token只读探针可用/) - assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) - assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/) - assert.match(source, /async requestOverseas\(/) - assert.match(source, /async requestOverseasBinary\(/) - assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/) + assert.equal(options.environment, 'test') + assert.throws(() => parseArguments(['--overseas-gateway', 'http://localhost:9300']), /one TEST gateway/) + assert.throws(() => parseArguments(['--environment', 'cn']), /TEST|test/) + assert.doesNotMatch(source, /async requestOverseas|this\.overseasToken|overseasAccessToken/) + assert.match(source, /this\.refreshToken = login\.refreshToken/) + assert.match(source, /system后新admin统一Token只读探针可用/) }) test('overseas business keeps its marker-scoped customer fixture with reusable credentials configured', () => { @@ -96,32 +83,31 @@ test('a fixed origin route does not require randomized order fixtures', () => { assert.match(source, /this\.context\.allowAnyExistingPallet = true/) }) -test('dual-token refresh replaces the whole session only after a complete response', async () => { - const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', +test('unified-session refresh replaces the whole session only after a complete response', async () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', username: 'contract-user', password: 'contract-password', modules: [], output: '' }) - Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 }) + Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', expiresAt: 1 }) run.request = async (method, path, body, token) => { assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh') assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '') return { method, path, http: 200, body: { code: '200', data: { - accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas', - overseasExpiresIn: 900, + accessToken: 'new-access', refreshToken: 'new-refresh', + expiresIn: 900, } }, requestId: 'refresh-request', traceId: 'refresh-trace' } } assert.equal(await run.refreshSession('contract'), true) assert.equal(run.token, 'new-access') assert.equal(run.refreshToken, 'new-refresh') - assert.equal(run.overseasToken, 'new-overseas') - assert.ok(run.overseasExpiresAt > Date.now()) + assert.ok(run.expiresAt > Date.now()) const frozen = { token: run.token, refreshToken: run.refreshToken, - overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt } + expiresAt: run.expiresAt } run.request = async (method, path) => ({ method, path, http: 200, body: { code: '200', data: { accessToken: 'partial-only' } }, requestId: 'partial-request', traceId: 'partial-trace' }) assert.equal(await run.refreshSession('incomplete-contract'), false) assert.deepEqual({ token: run.token, refreshToken: run.refreshToken, - overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen) + expiresAt: run.expiresAt }, frozen) }) test('entry has no built-in environment address or password and requires explicit execution options', () => { @@ -129,7 +115,7 @@ test('entry has no built-in environment address or password and requires explici assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), /缺少必填执行参数/) const options = parseArguments([ - '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--gateway', 'http://localhost:9000', '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', '--pda-username', 'contract-picker', '--pda-password', 'contract-picker-password', ]) @@ -139,7 +125,7 @@ test('entry has no built-in environment address or password and requires explici test('loading dependency chains require PDA credentials before making any HTTP requests', () => { for (const module of ['phase3-loading', 'phase3-container-loading', 'phase5-overseas-fulfillment']) { const options = parseArguments(['--gateway', 'http://localhost:9000', - '--overseas-gateway', 'http://localhost:9300', '--username', 'admin', '--password', 'fixture', + '--username', 'admin', '--password', 'fixture', '--modules', module]) options.pdaUsername = '' options.pdaPassword = '' @@ -152,7 +138,7 @@ test('loading dependency chains require PDA credentials before making any HTTP r }) test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { - const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', username: 'contract-user', password: 'contract-password', modules: [], output: '' }) run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') assert.equal(run.steps.at(-1)?.passed, false) diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java index 2f2ef9751..87d7baf34 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -11,14 +11,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class WarehouseDatabaseGuardConfigurationTest { @Test - void applicationDefaultsTargetDedicatedOverseasWarehouseDatabase() throws Exception { - var properties = new YamlPropertySourceLoader() - .load("application", new ClassPathResource("application.yml")) - .get(0); - - assertThat(properties.getProperty("spring.datasource.url")) - .asString() - .contains("baiyue_lastlag_warehouse") + void testConfigurationTargetsDedicatedWarehouseDatabaseViaRequiredExternalSettings() throws Exception { + var root = java.nio.file.Path.of(".."); + String nacos = java.nio.file.Files.readString(root.resolve("config/nacos/test/warehouse-overseas-service.yml")); + String compose = java.nio.file.Files.readString(root.resolve("deploy/docker-compose.yml")); + String example = java.nio.file.Files.readString(root.resolve("deploy/env/.env.test.example")); + assertThat(nacos).contains("url: ${WAREHOUSE_DB_URL}"); + assertThat(compose).contains("WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}"); + assertThat(example).contains("WAREHOUSE_OVERSEAS_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastlag_warehouse") .doesNotContain("baiyue_warehouse_overseas"); } -- 2.54.0 From 88c7fcaca9f564bfc15cc77bfdeae59afde566fa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 20:17:36 +0800 Subject: [PATCH 016/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/test/README.md | 2 + .../publish-nacos-config-contract.test.mjs | 93 +++++++++++++++++++ scripts/publish-nacos-config.ps1 | 15 ++- 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 scripts/publish-nacos-config-contract.test.mjs diff --git a/config/nacos/test/README.md b/config/nacos/test/README.md index 5680b52c3..09a6d2b79 100644 --- a/config/nacos/test/README.md +++ b/config/nacos/test/README.md @@ -7,3 +7,5 @@ 离线预检:`./scripts/publish-nacos-config.ps1 -ServerAddr '' -Namespace '' -DryRun`(从后端根运行,不联网、不登录)。正式发布去掉 DryRun,会修改指定 Nacos,需授权;认证从进程环境 NACOS_USERNAME/NACOS_PASSWORD 取得。失败立即停止,已发布项不自动撤销,保存本轮配置版本并在停写窗口重新发布完整匹配集合。 切换顺序、发布与恢复约束见 `../../../deploy/TEST单机部署执行清单.md`;文件存在不代表环境已发布。 + +发布脚本仅使用 Nacos v3:`POST /nacos/v3/auth/user/login` 取得 accessToken,`GET /nacos/v3/admin/core/namespace/list` 精确核对实际 Namespace ID,再逐项 `POST /nacos/v3/admin/cs/config`(`namespaceId`、`groupName`、`dataId`、`type=yaml`、UTF-8 表单内容)。发布响应必须为 `code=0` 且布尔值 `data=true`;旧 v1 路径、tenant/group 参数和裸 true 响应不再支持。离线合同:`node --test scripts/publish-nacos-config-contract.test.mjs`,全部 HTTP 由测试替身拦截。协议依据:[Admin API](https://nacos.io/en/docs/latest/manual/admin/admin-api/)、[鉴权](https://nacos.io/en/docs/latest/manual/admin/auth/)。 diff --git a/scripts/publish-nacos-config-contract.test.mjs b/scripts/publish-nacos-config-contract.test.mjs new file mode 100644 index 000000000..176d28e2f --- /dev/null +++ b/scripts/publish-nacos-config-contract.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const publisher = fileURLToPath(new URL('./publish-nacos-config.ps1', import.meta.url)) +const source = readFileSync(publisher, 'utf8') +const quote = value => `'${value.replaceAll("'", "''")}'` + +function run(mode) { + // Override the HTTP cmdlet before invoking the actual publisher: no network is possible. + const command = ` +$ErrorActionPreference = 'Stop' +$global:mode = ${quote(mode)} +$global:calls = 0 +$global:published = 0 +function Invoke-RestMethod { + param($Method, $Uri, $Body, $TimeoutSec, $ContentType) + $global:calls++ + if ($global:mode -eq 'dry') { throw 'DryRun attempted HTTP' } + if ($Uri -eq 'http://offline.invalid/nacos/v3/auth/user/login') { + if ($Method -ne 'Post' -or $Body.username -ne 'fixture-user' -or $Body.password -ne 'fixture-password') { throw 'Bad login request' } + if ($global:mode -eq 'login-failure') { return @{} } + return @{accessToken='fixture-token'} + } + if ($Uri -eq 'http://offline.invalid/nacos/v3/admin/core/namespace/list') { + if ($Method -ne 'Get' -or $Body.accessToken -ne 'fixture-token') { throw 'Bad namespace request' } + if ($global:mode -eq 'missing-namespace') { return @{code=0;data=@(@{namespace='other';namespaceShowName='fixture-id'})} } + if ($global:mode -eq 'namespace-failure') { return @{code=500;data=@(@{namespace='fixture-id'})} } + return @{code=0;data=@(@{namespace='fixture-id';namespaceShowName='TEST'})} + } + if ($Uri -ne 'http://offline.invalid/nacos/v3/admin/cs/config' -or $Method -ne 'Post') { throw 'Unexpected endpoint' } + $form = @{} + foreach ($pair in $Body.Split('&')) { + $parts = $pair.Split('=',2) + $form[[uri]::UnescapeDataString($parts[0])] = [uri]::UnescapeDataString($parts[1]) + } + if ($ContentType -ne 'application/x-www-form-urlencoded; charset=UTF-8' -or + $form.namespaceId -ne 'fixture-id' -or $form.groupName -ne 'BAIYUE' -or + $form.accessToken -ne 'fixture-token' -or $form.type -ne 'yaml' -or + $form.ContainsKey('tenant') -or $form.ContainsKey('group')) { throw 'Bad config form' } + $expected = Get-Content -LiteralPath (Join-Path ${quote(fileURLToPath(new URL('../config/nacos/test/', import.meta.url)))} $form.dataId) -Raw -Encoding UTF8 + if ($form.content -cne $expected) { throw 'YAML changed during encoding' } + if ($global:published -eq 0 -and $form.dataId -ne 'baiyue-common.yml') { throw 'Common config must publish first' } + $global:published++ + switch ($global:mode) { + 'false-data' { return @{code=0;data=$false} } + 'error-code' { return @{code=500;data=$true} } + 'missing-code' { return @{data=$true} } + 'string-data' { return @{code=0;data='true'} } + 'legacy-response' { return $true } + default { return @{code=0;data=$true} } + } +} +$failed = $false +try { + & ${quote(publisher)} -ServerAddr offline.invalid -Namespace fixture-id -Username fixture-user -Password fixture-password -DryRun:($global:mode -eq 'dry') +} catch { $failed = $true } +[ordered]@{failed=$failed; calls=$global:calls; published=$global:published} | ConvertTo-Json -Compress +` + const result = spawnSync(process.platform === 'win32' ? 'powershell.exe' : 'pwsh', + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')], + { encoding: 'utf8', timeout: 30_000 }) + assert.equal(result.status, 0, result.stderr || result.error?.message) + return JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1)) +} + +test('publisher uses v3 only and keeps TEST namespace validation', () => { + assert.doesNotMatch(source, /\/v1\/|\/v2\/|tenant=|\bgroup=/) + assert.match(source, /ValidateSet\('test'\)/) + assert.match(source, /Namespace -eq 'public'/) +}) + +test('DryRun makes zero HTTP calls without publishing', () => { + assert.deepEqual(run('dry'), { failed: false, calls: 0, published: 0 }) +}) + +test('v3 publishes all 14 original YAML documents with common first', () => { + assert.deepEqual(run('success'), { failed: false, calls: 16, published: 14 }) +}) + +for (const mode of ['login-failure', 'missing-namespace', 'namespace-failure']) { + test(`${mode} prevents all configuration writes`, () => { + assert.deepEqual(run(mode), { failed: true, calls: mode === 'login-failure' ? 1 : 2, published: 0 }) + }) +} + +for (const mode of ['false-data', 'error-code', 'missing-code', 'string-data', 'legacy-response']) { + test(`${mode} stops at the first failed publish`, () => { + assert.deepEqual(run(mode), { failed: true, calls: 3, published: 1 }) + }) +} diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 index 6005bb2e6..f20627105 100644 --- a/scripts/publish-nacos-config.ps1 +++ b/scripts/publish-nacos-config.ps1 @@ -16,12 +16,17 @@ if ($files.Count -ne 14) { throw 'Expected common configuration and 13 retained if ($DryRun) { $files | ForEach-Object { Write-Host "Would publish $($_.Name) -> $Namespace / $Group" }; return } if (-not $Username -or -not $Password) { throw 'NACOS_USERNAME and NACOS_PASSWORD are required' } $base = "http://$ServerAddr/nacos" -$login = Invoke-RestMethod -Method Post -Uri "$base/v1/auth/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 -if (-not $login.accessToken) { throw 'Nacos authentication failed' } +$login = Invoke-RestMethod -Method Post -Uri "$base/v3/auth/user/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 +if ([string]::IsNullOrWhiteSpace($login.accessToken)) { throw 'Nacos authentication failed' } +$namespaces = Invoke-RestMethod -Method Get -Uri "$base/v3/admin/core/namespace/list" -Body @{accessToken=$login.accessToken} -TimeoutSec 10 +if ($null -eq $namespaces.code -or $namespaces.code -ne 0 -or + -not @($namespaces.data | Where-Object { $_.namespace -ceq $Namespace }).Count) { + throw 'Nacos TEST namespace ID was not found or namespace query failed' +} foreach ($file in $files) { - $body = @{dataId=$file.Name; group=$Group; tenant=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} + $body = @{dataId=$file.Name; groupName=$Group; namespaceId=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} $pairs = foreach ($entry in $body.GetEnumerator()) { [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) } - $result = Invoke-RestMethod -Method Post -Uri "$base/v1/cs/configs" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 - if ([string]$result -ne 'true') { throw "Publish failed: $($file.Name)" } + $result = Invoke-RestMethod -Method Post -Uri "$base/v3/admin/cs/config" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 + if ($null -eq $result.code -or $result.code -ne 0 -or $result.data -isnot [bool] -or $result.data -ne $true) { throw "Publish failed: $($file.Name)" } Write-Host "Published $($file.Name) -> $Namespace / $Group" } -- 2.54.0 From 221227204dcaa9878cc5085e48455de1aa9ebd73 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 23:54:02 +0800 Subject: [PATCH 017/176] docs: record deployed test stack and remaining acceptance gates --- deploy/TEST单机部署执行清单.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/TEST单机部署执行清单.md b/deploy/TEST单机部署执行清单.md index 085b9618f..0f94fd632 100644 --- a/deploy/TEST单机部署执行清单.md +++ b/deploy/TEST单机部署执行清单.md @@ -1,6 +1,6 @@ # TEST 单机部署执行清单 -本清单是本地可审核目标,尚未部署或完成真实环境验证。唯一入口:`docker-compose.yml`、`service-map.yml`、`env/.env.test.example` 与 `../config/nacos/test/`。正式规则见外层 `docs/测试环境单机部署与配置契约.md`、`docs/统一认证与服务通信契约.md`。 +本清单定义部署顺序与门禁。单 TEST 统一架构现已部署,13 个保留服务与双 PDA H5 已检查健康;真实 HTTP 与目标事件故障恢复通过,页面核心流程及其余验收缺口仍未全部关闭。唯一入口:`docker-compose.yml`、`service-map.yml`、`env/.env.test.example` 与 `../config/nacos/test/`。正式规则见外层 `docs/测试环境单机部署与配置契约.md`、`docs/统一认证与服务通信契约.md`。 ## 发布前清单 -- 2.54.0 From f87caf9dd1ec832302b585180632dd4228f90a43 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 00:23:06 +0800 Subject: [PATCH 018/176] fix(lastleg): retry shipping nodes until loading handover exists --- .../ContainerShippingNodeConsumer.java | 2 -- .../ContainerShippingNodeConsumerTest.java | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index 1801f6c13..cb1d1b5de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -53,8 +53,6 @@ public class ContainerShippingNodeConsumer { return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } - gates.initialize(payload.containerBookingId(), payload.oceanBookingId(), - payload.containerNo(), 0, 1); ContainerShippingGate gate = gates.lock(payload.containerBookingId()); if (gate == null) throw new EventDependencyNotReadyException("船节点柜级投影尚未就绪"); if (!payload.containerNo().equals(gate.getContainerNo())) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index a9ea0af18..f88a1b3d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.shipping.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventDependencyNotReadyException; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -23,6 +24,8 @@ import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,6 +45,25 @@ class ContainerShippingNodeConsumerTest { ReflectionTestUtils.setField(consumer, "objectMapper", json); } + @Test + void shippingNodeBeforeLoadingHandoverRemainsRetryable() { + EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174106", "PICKUP_READY", 80, 5, + Instant.parse("2026-08-21T10:00:00Z")); + + assertThatThrownBy(() -> consumer.consume(event)) + .isInstanceOf(EventDependencyNotReadyException.class); + verify(gates, never()).initialize(anyString(), anyString(), anyString(), anyLong(), anyLong()); + verify(gates, never()).updateById(any(ContainerShippingGate.class)); + verify(inboxes, never()).updateById(any(LastlegEventInbox.class)); + + ContainerShippingGate gate = gate("AWAITING_PICKUP_READY", 0, 0, + Instant.parse("2026-08-20T10:00:00Z")); + when(gates.lock("CB-1")).thenReturn(gate); + assertThat(consumer.consume(event).outcome()) + .isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED); + assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE"); + } + @Test void pickupReadyMonotonicallyOpensReceivingGate() { ContainerShippingGate gate = gate("AWAITING_PICKUP_READY", 70, 4, Instant.parse("2026-08-20T10:00:00Z")); -- 2.54.0 From e5832d5c670d83bcb90eaccb1d7a64c2d1a3e033 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 11:03:47 +0800 Subject: [PATCH 019/176] fix(firstleg): refresh inbound picking progress after task completion --- .../firstleg/service/LoadingPickService.java | 2 +- .../service/LoadingPickServiceTest.java | 35 +++++++++++++++++++ ...e3-loading-dynamic-final-contract.test.mjs | 2 ++ .../phase3-loading-finalize.mjs | 6 ++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 9af36a61d..c4c5d2571 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -690,7 +690,6 @@ public class LoadingPickService { } runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, command.getOperatorId(), command.getOperatorName())); - repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName()); runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); for (GroupSnapshot group : taskGroups) { runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(), @@ -703,6 +702,7 @@ public class LoadingPickService { && repository.completeAccepted(taskId, command.getOperatorId(), command.getOperatorName()) != 1) { throw new CompletionProcessingException("TASK_FINALIZE", conflict("PDA任务完成状态已变化")); } + repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName()); if (!completionCommands.complete(command.getId(), worker)) { throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化")); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 8597ea9d6..448c86838 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -1060,6 +1060,41 @@ class LoadingPickServiceTest { verify(completionCommands).complete(900L, "worker-1"); } + @ParameterizedTest + @ValueSource(strings = {"COMPLETING", "COMPLETED"}) + void workerRefreshesInboundProgressAfterTaskCompletionIncludingRetry(String initialTaskStatus) { + LoadingPickTaskEntity task = task(); + task.setTaskStatus(initialTaskStatus); + when(repository.task(20L)).thenReturn(task); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenAnswer(invocation -> { + task.setTaskStatus("COMPLETED"); + return 1; + }); + AtomicReference projectedStatus = new AtomicReference<>(); + doAnswer(invocation -> { + projectedStatus.set("COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED" : "IN_PROGRESS"); + return null; + }).when(repository).syncInboundProjections(20L, 9L, "operator"); + when(completionCommands.complete(925L, "worker-projection")).thenReturn(true); + + service.processCompletion(completionCommand(925L, "async-projection"), "worker-projection"); + + assertThat(projectedStatus.get()).isEqualTo("COMPLETED"); + verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator"); + if ("COMPLETING".equals(initialTaskStatus)) { + InOrder finalization = inOrder(repository, completionCommands); + finalization.verify(repository).completeAccepted(20L, 9L, "operator"); + finalization.verify(repository).syncInboundProjections(20L, 9L, "operator"); + finalization.verify(completionCommands).complete(925L, "worker-projection"); + } else { + verify(repository, never()).completeAccepted(anyLong(), anyLong(), anyString()); + } + } + @ParameterizedTest(name = "truck state {0} never participates in PDA completion") @ValueSource(strings = {"NO_APPOINTMENT", "DRAFT", "IN_PROGRESS", "COMPLETED"}) void completionWorkerIsIndependentOfEveryTruckAppointmentState(String truckState) { diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 024329ed1..b731ac364 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -121,6 +121,8 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi }) test('downstream finalizer uses the frozen loading complete contract without truck coupling', () => { + assert.match(finalize, /unifiedDetail\?\.summary\?\.loading\?\.status === 'COMPLETED'/) + assert.match(finalize, /unifiedRow\?\.loading\?\.status === 'COMPLETED'/) assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index cc38656b6..2456d464c 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -429,6 +429,9 @@ export async function run(context) { id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'LOADED' && Boolean(orderNo), `orderId=${id(unifiedDetail?.summary?.orderId) || 'missing'}, mainStatus=${unifiedDetail?.summary?.mainStatus || 'missing'}, orderNo=${orderNo || 'missing'}`, OPTIONS) + context.assert(MODULE, '装柜完成后订单详情拣货汇总同步完成', unifiedDetailResponse, + unifiedDetail?.summary?.loading?.status === 'COMPLETED', + `loadingStatus=${unifiedDetail?.summary?.loading?.status || 'missing'}`, OPTIONS) if (id(unifiedDetail?.summary?.orderId) !== orderId || unifiedDetail?.summary?.mainStatus !== 'LOADED' || !orderNo) return const unifiedListPath = `/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}` const unifiedListResponse = await context.request('GET', unifiedListPath) @@ -437,6 +440,9 @@ export async function run(context) { context.assert(MODULE, '统一订单列表投影同一订单为 LOADED', unifiedListResponse, Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED', `orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS) + context.assert(MODULE, '装柜完成后订单列表拣货汇总同步完成', unifiedListResponse, + unifiedRow?.loading?.status === 'COMPLETED', + `loadingStatus=${unifiedRow?.loading?.status || 'missing'}`, OPTIONS) const evidencePath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(id(fixture.canonicalPdaTaskId))}/evidence?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` const immutableEvidenceResponse = await context.request('GET', evidencePath) -- 2.54.0 From c7945ad8fd53f928d40304272e86ff00c6d38dd7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 11:18:40 +0800 Subject: [PATCH 020/176] fix(api): preserve null data in response envelopes --- common-core/pom.xml | 1 + .../baiyue/common/core/api/ApiResponse.java | 4 +- .../web/ApiResponseSerializationTest.java | 43 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java diff --git a/common-core/pom.xml b/common-core/pom.xml index 956d0ca79..2ef4508d3 100644 --- a/common-core/pom.xml +++ b/common-core/pom.xml @@ -14,6 +14,7 @@ baiyue-common-core + com.fasterxml.jackson.corejackson-annotations org.springframeworkspring-web com.baomidou diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java b/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java index d88fd5b64..488845219 100644 --- a/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java +++ b/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java @@ -1,5 +1,7 @@ package com.baiyue.common.core.api; +import com.fasterxml.jackson.annotation.JsonInclude; + import java.time.Instant; /** @@ -8,7 +10,7 @@ import java.time.Instant; public record ApiResponse( String code, String message, - T data, + @JsonInclude(JsonInclude.Include.ALWAYS) T data, String requestId, String traceId, Instant timestamp) { diff --git a/common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java b/common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java new file mode 100644 index 000000000..d1aa71cd1 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java @@ -0,0 +1,43 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseSerializationTest { + @Test + void preservesEnvelopeDataUnderNonNullConfiguration() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withPropertyValues("spring.jackson.default-property-inclusion=non_null") + .run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + JsonNode success = mapper.readTree(mapper.writeValueAsString(ApiResponse.success(null))); + assertThat(success.path("code").asText()).isEqualTo("200"); + assertThat(success.has("data")).isTrue(); + assertThat(success.get("data").isNull()).isTrue(); + assertThat(success.has("requestId")).isFalse(); + + JsonNode failure = mapper.readTree(mapper.writeValueAsString( + ApiResponse.failure(ResultCode.CONFLICT, "conflict", "request-1", "trace-1"))); + assertThat(failure.path("code").asText()).isEqualTo("A0409"); + assertThat(failure.has("data")).isTrue(); + assertThat(failure.get("data").isNull()).isTrue(); + assertThat(failure.path("requestId").asText()).isEqualTo("request-1"); + assertThat(failure.path("traceId").asText()).isEqualTo("trace-1"); + + JsonNode populated = mapper.readTree(mapper.writeValueAsString( + ApiResponse.success(Map.of("status", "SUBMITTED")))); + assertThat(populated.path("data").path("status").asText()).isEqualTo("SUBMITTED"); + }); + } +} -- 2.54.0 From 6731be1a6bfa4cd0b27bd853ee5372ebf77a2460 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 11:29:02 +0800 Subject: [PATCH 021/176] fix(auth): avoid gap locks when creating accounts --- .../service/AccountManagementService.java | 13 +++++- .../service/AccountManagementServiceTest.java | 45 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index a8877efa2..5f4fdc138 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -9,6 +9,7 @@ import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DuplicateKeyException; import com.baiyue.common.audit.BusinessAudit; import java.util.Locale; @@ -63,7 +64,9 @@ public class AccountManagementService { String appCode = request.appCode() == null ? "MERCHANT" : request.appCode().trim().toUpperCase(Locale.ROOT); validateScope(appCode, request.tenantId()); - AuthAccount existing = repository.findById(request.accountId()); + // Locking a missing key takes an InnoDB gap lock and can deadlock + // unrelated account inserts. The unique indexes arbitrate creation. + AuthAccount existing = repository.findCurrentById(request.accountId()); if (existing != null) { if (sameIdentity(existing, request, appCode)) return summary(existing); throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用"); @@ -88,7 +91,13 @@ public class AccountManagementService { account.setDefaultMembershipId(request.membershipId()); account.setDefaultAppCode(appCode); account.setDeleted(0); - repository.insert(account); + try { + repository.insert(account); + } catch (DuplicateKeyException duplicate) { + AuthAccount winner = repository.findById(request.accountId()); + if (winner != null && sameIdentity(winner, request, appCode)) return summary(winner); + throw new BizException(ResultCode.CONFLICT, "认证账号标识或登录账号已被使用"); + } return summary(account); } diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index ee951962c..f3fa7cd0a 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -20,6 +20,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.dao.DuplicateKeyException; import jakarta.annotation.Resource; @@ -85,7 +86,7 @@ class AccountManagementServiceTest { } @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() { - when(repository.findById(20L)).thenReturn(null); + when(repository.findCurrentById(20L)).thenReturn(null); when(repository.existsByUsername("platform.user")).thenReturn(false); var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1", @@ -97,6 +98,7 @@ class AccountManagementServiceTest { assertEquals("PLATFORM", captor.getValue().getDefaultAppCode()); assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash())); assertNotEquals("Password1", captor.getValue().getPasswordHash()); + verify(repository, never()).findById(20L); } @Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() { @@ -107,7 +109,7 @@ class AccountManagementServiceTest { } @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { - when(repository.findById(20L)).thenReturn(null); + when(repository.findCurrentById(20L)).thenReturn(null); when(repository.existsByUsername("merchant.user")).thenReturn(false); var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1", @@ -141,6 +143,7 @@ class AccountManagementServiceTest { account.setUsername("platform.user"); account.setDisplayName("平台用户"); account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + when(repository.findCurrentById(10L)).thenReturn(account); var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")); @@ -149,6 +152,44 @@ class AccountManagementServiceTest { verify(repository, never()).insert(any()); } + @Test void concurrentInsertOfSameIdentityReturnsDurableWinner() { + account.setUsername("platform.user"); account.setDefaultTenantId(0L); + account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); + account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + when(repository.findCurrentById(10L)).thenReturn(null); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + + var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", + "平台用户", 0L, 30L, "PLATFORM")); + + assertEquals(10L, result.accountId()); + var order = inOrder(repository); + order.verify(repository).findCurrentById(10L); + order.verify(repository).existsByUsername("platform.user"); + order.verify(repository).insert(any()); + order.verify(repository).findById(10L); + verify(repository, never()).save(any()); + } + + @Test void concurrentInsertOfDifferentIdentityIsRejected() { + when(repository.findCurrentById(10L)).thenReturn(null); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + + assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(10L, + "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM"))); + verify(repository, never()).save(any()); + } + + @Test void concurrentUsernameConflictDoesNotCreateOrReturnAnotherAccount() { + when(repository.findCurrentById(20L)).thenReturn(null); + when(repository.findById(20L)).thenReturn(null); + doThrow(new DuplicateKeyException("username conflict")).when(repository).insert(any()); + + assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(20L, + "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM"))); + verify(repository, never()).save(any()); + } + @Test void synchronizeDisplayNameRevokesExistingSessions() { account.setDisplayName("旧名称"); -- 2.54.0 From c7f880300c12fcd5f99cd94273bfd4d51acbad35 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 11:48:14 +0800 Subject: [PATCH 022/176] fix(customs): keep declaration pending while exceptions remain --- .../service/ContainerCustomsService.java | 7 +++- .../ContainerCustomsServiceTest.java | 41 +++++++++++++++++++ ...s-wallet-and-pallet-cost-contract.test.mjs | 3 +- ...phase4-overseas-wallet-and-pallet-cost.mjs | 2 +- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index b4ab07155..f05f4adeb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -428,8 +428,11 @@ public class ContainerCustomsService { if (mapper.insertClearanceResult(resultId, batch.id(), batch.customsContainerId(), type, resultStatus, batch.customsEntryNo(), request.occurredAt(), trim(request.resolution()), request.evidenceFileId(), operatorId, operatorName, null) != 1) throw conflict("海关结果保存失败"); - if ("OPEN".equals(resultStatus)) mapper.markDeclarationResultPending(batch.id()); - else mapper.closeDeclarationBatch(batch.id()); + if ("OPEN".equals(resultStatus) || mapper.openClearanceResultCount(batch.id()) > 0) { + mapper.markDeclarationResultPending(batch.id()); + } else { + mapper.closeDeclarationBatch(batch.id()); + } ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); if (container != null) history(container, "RECORD_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), type, operatorId, operatorName); completeCommand(operatorId, action, key, resultId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 279227547..153637d77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -4,11 +4,14 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest; import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ClearanceResultRequest; import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; import com.baiyue.firstleg.customs.container.service.ContainerCustomsService; import com.baiyue.firstleg.customs.client.CustomsFileClient; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.*; import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; @@ -438,6 +441,44 @@ class ContainerCustomsServiceTest { verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L)); } + @ParameterizedTest + @CsvSource({ + "INSPECTION,true,1,true", + "RELEASED,false,2,true", + "OTHER,true,0,false", + "RELEASED,false,0,false", + "DETAINED,false,1,true" + }) + void newlyResolvedResultDoesNotCloseBatchWithOtherOpenExceptions( + String type, boolean resolved, int openResults, boolean remainsPending) { + var at = java.time.LocalDateTime.of(2026,9,30,11,0); + var batch = new ContainerCustomsMapper.DeclarationBatchRow( + 41L,2L,9L,"CD-LP9-01","RESULT_PENDING",3,"CUSTOMS-001","admin",at, + "admin",at,"",4,at,at); + String status = "RELEASED".equals(type) || resolved ? "RESOLVED" : "OPEN"; + when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("result-key"),anyString())).thenReturn(1); + when(mapper.lockDeclarationBatch(41L)).thenReturn(batch); + when(mapper.insertClearanceResult(anyLong(),eq(41L),eq(2L),eq(type),eq(status), + eq("CUSTOMS-001"),eq(at),eq("人工处理说明"),isNull(),eq(5L),eq("admin"),isNull())).thenReturn(1); + when(mapper.openClearanceResultCount(41L)).thenReturn(openResults); + when(mapper.completeCommand(eq(5L),anyString(),eq("result-key"),anyLong())).thenReturn(1); + when(mapper.clearanceResult(anyLong())).thenAnswer(call -> new ContainerCustomsMapper.ClearanceResultRow( + call.getArgument(0),41L,2L,type,status,"CUSTOMS-001",at,"人工处理说明",null,5L,"admin",at,null)); + + var result = service.addClearanceResult(41L,5L,"admin","result-key", + new ClearanceResultRequest(4,type,at,"人工处理说明",null,resolved)); + + assertThat(result.resultStatus()).isEqualTo(status); + if (remainsPending) { + verify(mapper).markDeclarationResultPending(41L); + verify(mapper,never()).closeDeclarationBatch(anyLong()); + } else { + verify(mapper).closeDeclarationBatch(41L); + verify(mapper,never()).markDeclarationResultPending(anyLong()); + } + verifyNoInteractions(events,files); + } + private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index 6d447dff7..af1766291 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -53,7 +53,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释 test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () => { for (const token of [ '/api/platform/v1/pallets/summary', - '/api/platform/v1/warehouse-admin/pallets/summary', + '/api/platform/v1/overseas-warehouse-resources/pallets/summary', '/pallets/summary?page=1&size=20', 'OVERSEAS_CONTAINER', 'DESTUFF_PALLET', @@ -61,6 +61,7 @@ test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () 'sourceFeeId', 'pollRequests=', ]) assert.ok(source.includes(token), token) + assert.doesNotMatch(source, /\/warehouse-admin\/pallets\/summary/) assert.match(source, /Number\(d\.totalCount\) === Number\(d\.idleCount\) \+ Number\(d\.occupiedCount\)/) assert.match(source, /allocated === money\(fact\.expectedPalletCost\)/) assert.match(source, /delivered\.reduce[\s\S]*fact\.expectedPalletCost/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index ac32a2cbc..9fb7d1a42 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -304,7 +304,7 @@ async function verifyPalletSummaries(context, warehouseId) { success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount']) && Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount), `total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`) - const overseas = await context.request('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20') + const overseas = await context.request('GET', '/api/platform/v1/overseas-warehouse-resources/pallets/summary?page=1&size=20') const o = overseas?.body?.data assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas, success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']) -- 2.54.0 From 4a14001a09e3a446d43ea27d1dbe54fc180a9a93 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:09:19 +0800 Subject: [PATCH 023/176] fix(lastleg): restore dispatch returns from immutable pick source and expose customer receipts --- .../MerchantDeliveryController.java | 28 ++++- .../delivery/dto/DeliveryDocumentDtos.java | 1 + .../service/DeliveryDocumentService.java | 11 ++ .../service/DispatchPdaWorkflowService.java | 7 +- .../mapper/InventoryCommandMapper.java | 18 ++- .../openapi/delivery-documents-v1.yaml | 37 ++++++- .../MerchantDeliveryControllerTest.java | 50 +++++++++ .../service/DeliveryDocumentServiceTest.java | 38 +++++++ .../DispatchPdaWorkflowServiceTest.java | 5 + .../DispatchShipmentSourceQueryTest.java | 103 ++++++++++++++++++ .../command/InventoryCommandServiceTest.java | 4 +- 11 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 5adc2ebe3..9c0827c27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -5,20 +5,24 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; +import org.springframework.http.*; 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.nio.charset.StandardCharsets; @RestController @RequestMapping("/api/merchant/v1/last-leg/delivery-orders") public class MerchantDeliveryController { @Resource private DeliveryTransferService service; + @Resource private DeliveryDocumentService documents; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") @@ -42,9 +46,29 @@ public class MerchantDeliveryController { return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo)); } + @GetMapping("/{deliveryNo}/documents/receipt") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") + public ApiResponse receipts( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String deliveryNo) { + return ApiResponse.success(documents.merchantReceipts(customer(user), deliveryNo)); + } + + @GetMapping("/{deliveryNo}/documents/receipt/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") + public ResponseEntity receiptDownload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @PathVariable long fileId) { + DeliveryDocumentService.Download downloaded = documents.merchantReceiptDownload(customer(user), deliveryNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString()) + .body(downloaded.content()); + } + private String customer(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + if (!"MERCHANT".equals(user.appCode()) || user.tenantId() <= 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); return String.valueOf(user.tenantId()); } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java index 9d3e12abf..97240b477 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java @@ -16,6 +16,7 @@ public final class DeliveryDocumentDtos { @NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { } public record File(Long fileId, String fileName, String contentType, long sizeBytes, LocalDateTime uploadedAt) { } + public record MerchantReceipts(String deliveryNo, List files) { } public record Category(DeliveryDocumentCategory category, String status, int fileCount, List files) { } public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { } public record Summary(int uploadedCategoryCount, int totalCategoryCount, List categories) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 9a2b3b6a0..600911e7d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -37,6 +37,17 @@ public class DeliveryDocumentService { public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) { + Detail snapshot = detail(customerId, deliveryNo); + return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream() + .filter(category -> category.category() == DeliveryDocumentCategory.RECEIPT) + .flatMap(category -> category.files().stream()).toList()); + } + + public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) { + return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null); + } + public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, String operatorId, MultipartFile file) { DeliveryOrder order = require(customerId, deliveryNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 9d9f22994..7b64e2c43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -82,8 +82,11 @@ public class DispatchPdaWorkflowService { public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, String operatorId, String operatorName, DispatchPdaRequests.Pick request) { - DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = order.getCustomerId(); + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); + // Transfer and picking serialize on the same dispatch before taking task/inventory locks. + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); if (task == null) throw conflict("Dispatch PDA task is not available for picking"); if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index f5b7c5668..1438b0a7b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -47,12 +47,24 @@ public interface InventoryCommandMapper { "AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)") int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); - @Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " + - "WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " + - "AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0") + @Select("SELECT COALESCE(-SUM(l.delta_packages),0) FROM ll_inventory_ledger l " + + "JOIN ll_outbound_order o ON o.customer_id=l.customer_id AND o.outbound_no=#{outboundNo} " + + "AND o.overseas_warehouse_id=l.overseas_warehouse_id AND o.status='OUTBOUNDED' " + + "WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId} " + + "AND l.sku_code=#{skuCode} AND l.business_type='SHIP' AND l.delta_packages<0 " + + "AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " + + "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + + "ON p.customer_id=t.customer_id AND p.task_id=t.id " + + "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':'," + + "p.dispatch_item_id,':',p.idempotency_key,':SHIP'))) " + + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no))") long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); + @Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " + "WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " + "AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0") diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index 70ce3be2c..ad82f7431 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -3,13 +3,42 @@ info: title: 派送单三类资料 version: 1.0.0 description: | - 所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。 + 平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] paths: + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + get: + summary: 查询本企业派送单签收资料 + description: 权限merchant:warehouse:delivery:list。仅当前已绑定RECEIPT文件,尚无资料返回files空数组;其他客户派送单统一不存在,不接受customerId或历史版本。 + responses: + '200': + description: 本企业签收资料或统一业务错误 + content: + application/json: + schema: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: { data: { $ref: '#/components/schemas/MerchantReceipts' } } + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt/files/{fileId}/download: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 下载本企业派送单当前签收文件 + description: 权限merchant:warehouse:delivery:list。服务端校验客户、派送单、当前RECEIPT绑定及File对象归属;其他客户、其他派送单、非签收类别、仅上传未保存或已解绑文件返回不存在。文件经服务端代理,附件下载、no-store、nosniff;不返回对象存储地址。 + responses: + '200': + description: 原始文件字节;业务失败为统一JSON错误 + content: + application/octet-stream: { schema: { type: string, format: binary } } + application/json: { schema: { $ref: '#/components/schemas/Envelope' } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents: parameters: - { $ref: '#/components/parameters/DeliveryNo' } @@ -103,6 +132,12 @@ components: CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } } schemas: + MerchantReceipts: + type: object + required: [deliveryNo, files] + properties: + deliveryNo: { type: string } + files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } CategoryCode: type: string enum: [LOADING_PHOTO, BOL, RECEIPT] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java index 7ff48fee5..ff88c80c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.delivery.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.validation.Validation; import org.junit.jupiter.api.Test; @@ -29,9 +31,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class MerchantDeliveryControllerTest { private final DeliveryTransferService service = mock(DeliveryTransferService.class); + private final DeliveryDocumentService documents = mock(DeliveryDocumentService.class); private final MerchantDeliveryController controller = controller(); private final DeliveryRequests.Query query = new DeliveryRequests.Query( 1, 20, null, null, null, null, null, null, null, null, null, null, null, null); @@ -46,14 +50,20 @@ class MerchantDeliveryControllerTest { Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class, String.class, DeliveryRequests.Transfer.class); Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class); + Method receipts = MerchantDeliveryController.class.getMethod("receipts", AuthenticatedUser.class, String.class); + Method download = MerchantDeliveryController.class.getMethod("receiptDownload", AuthenticatedUser.class, String.class, long.class); assertPermission(page, OverseasPermissions.MERCHANT_DELIVERY_LIST); assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(receipts, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(download, OverseasPermissions.MERCHANT_DELIVERY_LIST); assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE); assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST, OverseasPermissions.MERCHANT_DELIVERY_CREATE); assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(receipts.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(download.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents()) .noneMatch(component -> "customerId".equals(component.getName()))); @@ -80,6 +90,36 @@ class MerchantDeliveryControllerTest { assertThrows(ResponseStatusException.class, () -> controller.page(platform, query)); assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100")); assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest())); + assertThrows(ResponseStatusException.class, () -> controller.receipts(platform, "DEL-100")); + assertThrows(ResponseStatusException.class, () -> controller.receiptDownload(platform, "DEL-100", 13)); + } + + @Test + void merchantReceiptDownloadKeepsTokenScopeAndReturnsSafeAttachmentBytes() { + var file = new DeliveryDocumentDtos.File(13L, "签收.pdf", "application/pdf", 2, LocalDateTime.now()); + when(documents.merchantReceiptDownload("100","DEL-100",13)) + .thenReturn(new DeliveryDocumentService.Download(file, new byte[]{3,4})); + controller.receipts(user(100L,"MERCHANT"),"DEL-100"); + var response = controller.receiptDownload(user(100L,"MERCHANT"),"DEL-100",13); + verify(documents).merchantReceipts("100","DEL-100"); + verify(documents).merchantReceiptDownload("100","DEL-100",13); + assertTrue(Arrays.equals(new byte[]{3,4},response.getBody())); + assertEquals("nosniff",response.getHeaders().getFirst("X-Content-Type-Options")); + assertEquals("no-store",response.getHeaders().getCacheControl()); + assertTrue(response.getHeaders().getContentDisposition().isAttachment()); + } + + @Test + void receiptReadsRejectMissingIdentityInvalidCustomerAndNonMerchantBeforeServiceAccess() { + assertEquals(401, assertThrows(ResponseStatusException.class, + () -> controller.receipts(null,"DEL-100")).getStatusCode().value()); + for (AuthenticatedUser invalid : List.of(user(-1L,"MERCHANT"), user(100L,"PLATFORM"), user(100L,"PDA"))) { + assertEquals(403, assertThrows(ResponseStatusException.class, + () -> controller.receipts(invalid,"DEL-100")).getStatusCode().value()); + assertEquals(403, assertThrows(ResponseStatusException.class, + () -> controller.receiptDownload(invalid,"DEL-100",13)).getStatusCode().value()); + } + org.mockito.Mockito.verifyNoInteractions(documents); } @Test @@ -101,6 +141,15 @@ class MerchantDeliveryControllerTest { assertTrue(contract.contains("merchant:warehouse:delivery:list")); assertTrue(contract.contains("merchant:warehouse:delivery:create")); assertFalse(contract.contains("name: customerId")); + String receiptContract = Files.readString(Path.of("src/main/resources/openapi/delivery-documents-v1.yaml")); + String merchantPaths = slice(receiptContract, + " /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt:", + " /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:"); + assertTrue(merchantPaths.contains("merchant:warehouse:delivery:list")); + assertTrue(merchantPaths.contains("/documents/receipt/files/{fileId}/download:")); + assertFalse(merchantPaths.contains("#/components/parameters/CustomerId")); + assertFalse(merchantPaths.contains("\n post:")); + assertFalse(merchantPaths.contains("\n put:")); } private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) { @@ -128,6 +177,7 @@ class MerchantDeliveryControllerTest { private MerchantDeliveryController controller() { MerchantDeliveryController value = new MerchantDeliveryController(); ReflectionTestUtils.setField(value, "service", service); + ReflectionTestUtils.setField(value, "documents", documents); return value; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index f28759248..6eadd68a1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -151,6 +151,44 @@ class DeliveryDocumentServiceTest { assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); } + @Test void merchantReadsOnlyCurrentReceiptFilesAndDownloadsWithAggregateOwnershipChecks() throws Exception { + order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of( + new Category(BOL,"UPLOADED",1,List.of(file(11))), + new Category(RECEIPT,"UPLOADED",1,List.of(file(13)))))); + when(orders.findByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(files.download("100",10,RECEIPT,13)).thenReturn(new byte[]{3,4}); + assertThat(service.merchantReceipts("100","DL-1").files()).containsExactly(file(13)); + assertThat(service.merchantReceiptDownload("100","DL-1",13).content()).containsExactly((byte)3,(byte)4); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",11)) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",99)) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.merchantReceipts("200","DL-1")) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.merchantReceiptDownload("200","DL-1",13)) + .isInstanceOf(ResponseStatusException.class); + verify(files,never()).download("100",10,RECEIPT,11); + verifyNoInteractions(revisions,operations); + } + @Test void merchantDoesNotExposeUploadedUnboundOrHistoricalRemovedReceipts() throws Exception { + when(orders.findByNo("100","DL-1")).thenReturn(order); + assertThat(service.merchantReceipts("100","DL-1").files()).isEmpty(); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",13)) + .isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(files,revisions,operations); + } + @Test void merchantReceiptDownloadRejectsFileOwnershipFailureBeforeFetchingBytes() throws Exception { + order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of( + new Category(RECEIPT,"UPLOADED",1,List.of(file(13)))))); + when(orders.findByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenThrow( + new ResponseStatusException(HttpStatus.BAD_REQUEST,"文件不属于当前客户、派送单或资料类别")); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",13)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("当前客户"); + verify(files,never()).download(anyString(),anyLong(),any(),anyLong()); + verifyNoInteractions(revisions,operations); + } private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 5b0ed594c..c7945e1e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -162,6 +162,7 @@ class DispatchPdaWorkflowServiceTest { when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); when(tasks.lockByDispatch("100", 1L)).thenReturn(task); when(operations.findKey("100", "pick-1")).thenReturn(replay); + when(orders.lockById("100", 1L)).thenReturn(order); DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); @@ -169,6 +170,10 @@ class DispatchPdaWorkflowServiceTest { verify(inventory, never()).allocate(any(), any(), any()); verify(picks, never()).insert((DispatchPdaPick) any()); verify(items, never()).bindReceivedPallets(any(), any()); + var locks = org.mockito.Mockito.inOrder(orders, tasks); + locks.verify(orders).findGlobalByDispatchNo("DSP-1"); + locks.verify(orders).lockById("100", 1L); + locks.verify(tasks).lockByDispatch("100", 1L); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java new file mode 100644 index 000000000..f96cd1be2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.inventory.command; + +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Execute the production source query rather than mocking a successful source lookup. */ +class DispatchShipmentSourceQueryTest { + private Connection database() throws Exception { + Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL"); + execute(connection, "CREATE TABLE ll_inventory_ledger (id BIGINT PRIMARY KEY, customer_id VARCHAR, position_id BIGINT, overseas_warehouse_id VARCHAR, sku_code VARCHAR, business_type VARCHAR, delta_packages BIGINT, business_order_no VARCHAR, outbound_order_no VARCHAR, idempotency_key VARCHAR)"); + execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)"); + execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)"); + execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)"); + execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)"); + execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')"); + return connection; + } + + private void execute(Connection connection, String sql) throws Exception { + try (var statement = connection.createStatement()) { statement.execute(sql); } + } + + private long shipped(Connection connection, String customerId, long positionId, String outboundNo, String sku) throws Exception { + String sql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", + String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()) + .replace("#{outboundNo}", "?").replace("#{customerId}", "?") + .replace("#{positionId}", "?").replace("#{skuCode}", "?"); + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, outboundNo); statement.setString(2, customerId); + statement.setLong(3, positionId); statement.setString(4, sku); + try (var result = statement.executeQuery()) { result.next(); return result.getLong(1); } + } + } + + private void scan(Connection connection) throws Exception { + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP')"); + } + + private void transfer(Connection connection) throws Exception { + execute(connection, "INSERT INTO ll_outbound_order VALUES (61,'100','OB-1','WH-1','OUTBOUNDED','DISPATCH_DELIVERY','DS-1',1)"); + } + + @Test + void resolvesOriginalScanFactsForBothTransferOrdersWithoutChangingTheLedger() throws Exception { + for (boolean scanFirst : new boolean[]{true, false}) { + try (Connection connection = database()) { + if (scanFirst) { scan(connection); transfer(connection); } + else { transfer(connection); scan(connection); } + assertEquals(8, shipped(connection, "100", 41, "OB-1", "SKU-1")); + assertEquals(8, shipped(connection, "100", 41, "OB-1", "SKU-1")); + try (var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT outbound_order_no, delta_packages FROM ll_inventory_ledger")) { + rows.next(); assertEquals("DS-1", rows.getString(1)); assertEquals(-8, rows.getLong(2)); + } + } + } + } + + @Test + void requiresExactCustomerWarehousePositionSkuAndPickCommandIdentity() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + assertEquals(0, shipped(connection, "200", 41, "OB-1", "SKU-1")); + assertEquals(0, shipped(connection, "100", 42, "OB-1", "SKU-1")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-2")); + assertEquals(0, shipped(connection, "100", 41, "OB-2", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='different-command'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='pick-1', packages=9"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_pick SET packages=8"); + execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='200'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='100',warehouse_id='WH-2'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-1',dispatch_order_id=2"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_task SET dispatch_order_id=1"); + execute(connection, "UPDATE ll_outbound_order SET status='WAIT_OUTBOUND'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_outbound_order SET status='OUTBOUNDED'"); + execute(connection, "UPDATE ll_outbound_order SET overseas_warehouse_id='WH-2'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + } + } + + @Test + void independentOutboundUsesItsOwnNumberAndDoesNotAcceptADispatchAlias() throws Exception { + try (Connection connection = database()) { + scan(connection); + execute(connection, "INSERT INTO ll_outbound_order VALUES (61,'100','OB-1','WH-1','OUTBOUNDED','WAREHOUSE_TRANSFER','DS-1',1)"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'100',41,'WH-1','SKU-1','SHIP',-3,'TRANSFER-1','OB-1','transfer-ship-1')"); + assertEquals(3, shipped(connection, "100", 41, "OB-1", "SKU-1")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 1dee12878..8184fccfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -432,7 +432,9 @@ class InventoryCommandServiceTest { String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()); assertTrue(shippedSql.contains("position_id=#{positionId}")); - assertTrue(shippedSql.contains("outbound_order_no=#{outboundNo}")); + assertTrue(shippedSql.contains("o.outbound_no=#{outboundNo}")); + assertTrue(shippedSql.contains("p.inventory_position_id=l.position_id")); + assertTrue(shippedSql.contains("p.idempotency_key,':SHIP'")); assertTrue(shippedSql.contains("business_type='SHIP'")); String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", String.class, String.class, String.class).getAnnotation(Select.class).value()); -- 2.54.0 From 2f0b2025902438f54eb566f0802830a18bbf5f77 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:22:47 +0800 Subject: [PATCH 024/176] fix(test): assign authenticated PDA operator in pallet matrix --- .../dropship-pallet-matrix-contract.test.mjs | 7 +++++++ scripts/admin-test-env-modules/dropship-pallet-matrix.mjs | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index d82528c10..65ea1283e 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -5,6 +5,13 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { sameOutboundTask } from './dropship-pallet-matrix.mjs' import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' +test('loading confirmation assigns the authenticated PDA operator from the target warehouse', () => { + const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') + assert.match(source, /domestic-pda-operators\?warehouseId=\$\{summary\.warehouseId\}/) + assert.match(source, /selectSessionPdaOperator\(operators, context\.pdaToken\)/) + assert.match(source, /assigneeId: String\(operator\.userId\)/) +}) + test('receiving verifier follows three independent inbound codes through interleaved progress', () => { const expected = [{ inboundCode: 'FBA', total: 5, received: 0 }, { inboundCode: 'DROPSHIP', total: 4, received: 0 }, { inboundCode: 'OVERSEAS', total: 3, received: 0 }] diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index f5bbb2025..b5d7d1856 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -14,6 +14,7 @@ import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from ' import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' import { receiveMixedContainer } from './mixed-container-receiving.mjs' import { exerciseOldCabinets } from './pda-loading-link-regression.mjs' +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' @@ -274,8 +275,12 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals const loadingDetail = await command('读取整份预装确认门禁', 'GET', `/api/platform/v1/first-leg/loading-plans/${plan.id}?warehouseId=${summary.warehouseId}`) check(`${label}全部成员可以确认预装`, null, loadingDetail.summary?.readiness?.canConfirm === true) + const operators = await command('查询当前仓国内PDA作业员', 'GET', + `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${summary.warehouseId}`) + const operator = selectSessionPdaOperator(operators, context.pdaToken) const confirmed = await command('确认整份预装', 'POST', `/api/platform/v1/first-leg/loading-plans/${plan.id}/confirm`, - { expectedVersion: Number(loadingDetail.summary.version) }, { 'Idempotency-Key': `${context.marker}-confirm` }) + { expectedVersion: Number(loadingDetail.summary.version), assigneeId: String(operator.userId) }, + { 'Idempotency-Key': `${context.marker}-confirm` }) const taskId = String(confirmed.readiness.pdaTaskId) const pda = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}` const task = await command('读取装柜PDA任务', 'GET', `${pda}?warehouseId=${summary.warehouseId}`) -- 2.54.0 From 5cb2a5145ffe6f48615044d7ccb7131f3143a62f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:32:59 +0800 Subject: [PATCH 025/176] fix(inventory): normalize FBA return SKU and match durable shipment command identity --- .../mapper/InventoryCommandMapper.java | 6 +- .../service/InventoryCommandService.java | 2 +- .../DispatchShipmentSourceQueryTest.java | 31 +++++++++- .../command/InventoryCommandServiceTest.java | 61 +++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index 1438b0a7b..dac3cac41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -56,10 +56,12 @@ public interface InventoryCommandMapper { "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + "ON p.customer_id=t.customer_id AND p.task_id=t.id " + + "JOIN ll_inventory_operation op ON op.customer_id=p.customer_id " + + "AND op.operation_type='SHIP' AND op.status='COMPLETED' " + + "AND op.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':',p.dispatch_item_id,':',p.idempotency_key,':SHIP') " + "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + - "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':'," + - "p.dispatch_item_id,':',p.idempotency_key,':SHIP'))) " + + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT(op.idempotency_key,':',op.id))) " + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no))") long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 540f25419..d606f263b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -294,7 +294,7 @@ public class InventoryCommandService { || source.maxReturnPackages() <= 0) { throw badRequest("A valid returned inventory source is required"); } - String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required"); + String sourceSku = sku(source.skuCode()); ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java index f96cd1be2..f79401126 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -18,6 +18,7 @@ class DispatchShipmentSourceQueryTest { execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)"); execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)"); execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)"); + execute(connection, "CREATE TABLE ll_inventory_operation (id BIGINT PRIMARY KEY, customer_id VARCHAR, operation_type VARCHAR, status VARCHAR, idempotency_key VARCHAR)"); execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)"); execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')"); return connection; @@ -40,7 +41,8 @@ class DispatchShipmentSourceQueryTest { } private void scan(Connection connection) throws Exception { - execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_operation VALUES (71,'100','SHIP','COMPLETED','DISPATCH:DS-1:31:pick-1:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP:71')"); } private void transfer(Connection connection) throws Exception { @@ -100,4 +102,31 @@ class DispatchShipmentSourceQueryTest { assertEquals(3, shipped(connection, "100", 41, "OB-1", "SKU-1")); } } + + @Test + void ordinaryFbaUnspecifiedSkuMatchesExactlyAndWrongSkuNeverFallsBack() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + execute(connection, "UPDATE ll_inventory_ledger SET sku_code='UNSPECIFIED'"); + assertEquals(8, shipped(connection, "100", 41, "OB-1", "UNSPECIFIED")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "WRONG-SKU")); + } + } + + @Test + void shipmentLedgerRequiresTheActualCompletedOperationIdentityRatherThanAPrefix() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + execute(connection, "UPDATE ll_inventory_ledger SET idempotency_key='DISPATCH:DS-1:31:pick-1:SHIP:72'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_inventory_ledger SET idempotency_key='DISPATCH:DS-1:31:pick-1:SHIP:71'"); + execute(connection, "UPDATE ll_inventory_operation SET customer_id='200'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_inventory_operation SET customer_id='100',status='PROCESSING'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_inventory_operation SET status='COMPLETED',operation_type='PICK'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 8184fccfd..4e0aa51c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -19,6 +19,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.apache.ibatis.annotations.Select; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -409,6 +412,63 @@ class InventoryCommandServiceTest { verify(ledgers, times(1)).insert(any(InventoryLedger.class)); } + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void ordinaryFbaReturnUsesTheStockInSkuNormalizationAndReplaysOnlyOnce(String sourceSku) { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + balance.setSkuCode("UNSPECIFIED"); + when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED")).thenReturn(8L); + when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); + when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn( + new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE")); + ReturnedInventoryRestoreRequest base = returnRequest(1); + ReturnedInventoryRestoreRequest request = new ReturnedInventoryRestoreRequest(base.exceptionNo(), base.outboundNo(), + base.overseasWarehouseId(), base.inboundOrderId(), + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, sourceSku, 5)), + base.receipts(), base.idempotencyKey(), base.evidenceFileIds()); + + ReturnedInventoryRestoreResponse first = service.restoreReturnedInventory("100", "7", request); + assertEquals(1, first.restoredPackages()); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue()); + assertTrue(service.restoreReturnedInventory("100", "7", request).replayed()); + verify(commands).shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED"); + verify(commands).restoredPackages("100", "RETURN_SOURCE:301:201", "UNSPECIFIED"); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(1)).insert(ledger.capture()); + assertEquals("UNSPECIFIED", ledger.getValue().getSkuCode()); + verify(positions, times(1)).insert(any(InventoryPosition.class)); + verify(balances, times(1)).updateById(any(InventoryBalance.class)); + } + + @Test + void nonEmptyWrongReturnSkuIsRejectedWithoutSearchingAnotherSkuOrWritingInventory() { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + balance.setSkuCode("UNSPECIFIED"); + when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + ReturnedInventoryRestoreRequest base = returnRequest(1); + ReturnedInventoryRestoreRequest request = new ReturnedInventoryRestoreRequest(base.exceptionNo(), base.outboundNo(), + base.overseasWarehouseId(), base.inboundOrderId(), + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "WRONG-SKU", 5)), + base.receipts(), base.idempotencyKey(), base.evidenceFileIds()); + + assertThrows(ResponseStatusException.class, () -> service.restoreReturnedInventory("100", "7", request)); + verify(commands).shippedPackages("100", 201L, "OUT-1", "WRONG-SKU"); + verify(commands, never()).shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED"); + verify(positions, never()).insert(any(InventoryPosition.class)); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(balances, never()).updateById(any(InventoryBalance.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); + } + @Test void rejectsReturnBeyondRemainingImmutableShipmentWithoutInventoryMutation() { InventoryBalance balance = balance(200L, 0); @@ -435,6 +495,7 @@ class InventoryCommandServiceTest { assertTrue(shippedSql.contains("o.outbound_no=#{outboundNo}")); assertTrue(shippedSql.contains("p.inventory_position_id=l.position_id")); assertTrue(shippedSql.contains("p.idempotency_key,':SHIP'")); + assertTrue(shippedSql.contains("l.idempotency_key=CONCAT(op.idempotency_key,':',op.id)")); assertTrue(shippedSql.contains("business_type='SHIP'")); String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", String.class, String.class, String.class).getAnnotation(Select.class).value()); -- 2.54.0 From 125ca34ba4f4176c885f675ddc62f50ca71c946f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:37:00 +0800 Subject: [PATCH 026/176] fix(outbound): atomically confirm platform mixed-container dispatch and tracking --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- .../OutboundDispatchAutoConfirmService.java | 16 ++--- .../phase3-carrier-schedule-query-schema.sql | 2 +- ...utboundDispatchAutoConfirmServiceTest.java | 72 ++++++++++++++++++- .../CarrierScheduleQuerySqlContractTest.java | 32 ++++++++- 5 files changed, 109 insertions(+), 15 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5a75105ef..d0ea8a737 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5259,7 +5259,7 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW BEGIN IF NOT EXISTS (SELECT 1 FROM fl_ocean_booking booking - WHERE booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id) THEN + WHERE booking.id=NEW.ocean_booking_id AND COALESCE(booking.customer_id,0)=NEW.customer_id) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询客户与订舱不一致'; END IF; IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java index 5880fdf02..9b89a4ae4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java @@ -1,10 +1,10 @@ package com.baiyue.firstleg.outbound.dispatch.service; -import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** Real proxy-owned transaction boundary for repeated system convergence after customs completion. */ @@ -12,19 +12,13 @@ import org.springframework.transaction.annotation.Transactional; public class OutboundDispatchAutoConfirmService { @Resource private OutboundDispatchService dispatch; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean confirmIfReady(long containerBookingId) { var current=dispatch.detail(containerBookingId); if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(current.aggregate().dispatchStatus()))return true; if(!current.aggregate().gate().passed())return false; - try{ - dispatch.confirm(containerBookingId,0L,"SYSTEM","CUSTOMS-AUTO-DISPATCH-"+containerBookingId, - new ActionRequest(current.aggregate().version(),"整柜报关完成且全部离仓门禁满足,系统自动确认离仓")); - return true; - }catch(BizException ex){ - var after=dispatch.detail(containerBookingId); - if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(after.aggregate().dispatchStatus()))return true; - throw ex; - } + dispatch.confirm(containerBookingId,0L,"SYSTEM","CUSTOMS-AUTO-DISPATCH-"+containerBookingId, + new ActionRequest(current.aggregate().version(),"整柜报关完成且全部离仓门禁满足,系统自动确认离仓")); + return true; } } diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index f8357e255..428238bd6 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -209,7 +209,7 @@ DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW BEGIN IF NOT EXISTS (SELECT 1 FROM fl_ocean_booking booking - WHERE booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id) THEN + WHERE booking.id=NEW.ocean_booking_id AND COALESCE(booking.customer_id,0)=NEW.customer_id) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询客户与订舱不一致'; END IF; IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java index 3e11cd5eb..73dde8142 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java @@ -2,15 +2,33 @@ package com.baiyue.firstleg.outbound.dispatch; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*; import com.baiyue.firstleg.outbound.dispatch.service.*; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.*; import org.mockito.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.NameMatchTransactionAttributeSource; +import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +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.mockito.Mockito.*; class OutboundDispatchAutoConfirmServiceTest { @@ -20,6 +38,58 @@ class OutboundDispatchAutoConfirmServiceTest { @Test void waitsWithoutWritingUntilEveryOtherGatePasses(){when(dispatch.detail(22L)).thenReturn(detail(false,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isFalse();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());} @Test void usesSystemIdentityAndStableIdempotencyKey(){when(dispatch.detail(22L)).thenReturn(detail(true,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch).confirm(eq(22L),eq(0L),eq("SYSTEM"),eq("CUSTOMS-AUTO-DISPATCH-22"),argThat(r->r.expectedVersion()==3));} @Test void concurrentReplayAlreadyConfirmedConvergesWithoutSecondMutation(){when(dispatch.detail(22L)).thenReturn(detail(true,"WAITING_FOR_LOADING",4));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());} - @Test void proxyOwnedAutoBoundaryAndConfirmMutationAreTransactional()throws Exception{assertThat(OutboundDispatchAutoConfirmService.class.getMethod("confirmIfReady",long.class).getAnnotation(Transactional.class)).isNotNull();assertThat(OutboundDispatchService.class.getMethod("confirm",long.class,long.class,String.class,String.class,ActionRequest.class).getAnnotation(Transactional.class)).isNotNull();} + @Test void proxyOwnedAutoBoundaryAndConfirmMutationAreTransactional()throws Exception{assertThat(OutboundDispatchAutoConfirmService.class.getMethod("confirmIfReady",long.class).getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.REQUIRES_NEW);assertThat(OutboundDispatchService.class.getMethod("confirm",long.class,long.class,String.class,String.class,ActionRequest.class).getAnnotation(Transactional.class)).isNotNull();} + @Test void anUncommittedDepartureCannotHideCarrierTaskCreationFailure(){ + when(dispatch.detail(22L)).thenReturn(detail(true,"READY_FOR_DISPATCH",3), detail(true,"WAITING_FOR_LOADING",4)); + BizException failure=new BizException(ResultCode.CONFLICT,"船司查询客户与订舱不一致"); + when(dispatch.confirm(anyLong(),anyLong(),anyString(),anyString(),any())).thenThrow(failure); + assertThatThrownBy(()->service.confirmIfReady(22L)).isSameAs(failure); + verify(dispatch,times(1)).detail(22L); + } + + @Test void afterCommitFailureRollsBackDepartureAndTaskTogetherThenIndependentRetryCommitsBoth(){ + JdbcDataSource database=new JdbcDataSource(); + database.setURL("jdbc:h2:mem:auto-dispatch-"+UUID.randomUUID()+";DB_CLOSE_DELAY=-1"); + JdbcTemplate jdbc=new JdbcTemplate(database); + jdbc.execute("CREATE TABLE business_fact(kind VARCHAR(32) PRIMARY KEY)"); + var manager=new DataSourceTransactionManager(database); + AtomicBoolean rejectCarrier=new AtomicBoolean(true); + when(dispatch.detail(22L)).thenAnswer(invocation->detail(true, + jdbc.queryForObject("SELECT COUNT(*) FROM business_fact WHERE kind='OUTBOUND'",Integer.class)>0 + ? "WAITING_FOR_LOADING" : "READY_FOR_DISPATCH",3)); + when(dispatch.confirm(anyLong(),anyLong(),anyString(),anyString(),any())).thenAnswer(invocation->{ + jdbc.update("INSERT INTO business_fact(kind) VALUES('OUTBOUND')"); + if(rejectCarrier.get())throw new BizException(ResultCode.CONFLICT,"carrier task insert failed"); + jdbc.update("INSERT INTO business_fact(kind) VALUES('CARRIER_QUERY')"); + return detail(true,"WAITING_FOR_LOADING",4); + }); + var dispatchAttributes=new NameMatchTransactionAttributeSource(); + dispatchAttributes.addTransactionalMethod("confirm",new RuleBasedTransactionAttribute()); + ProxyFactory dispatchFactory=new ProxyFactory(dispatch); + dispatchFactory.setProxyTargetClass(true); + dispatchFactory.addAdvice(new TransactionInterceptor(manager,dispatchAttributes)); + ReflectionTestUtils.setField(service,"dispatch",dispatchFactory.getProxy()); + ProxyFactory autoFactory=new ProxyFactory(service); + autoFactory.setProxyTargetClass(true); + autoFactory.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource())); + var auto=(OutboundDispatchAutoConfirmService)autoFactory.getProxy(); + AtomicReference callbackFailure=new AtomicReference<>(); + new TransactionTemplate(manager).executeWithoutResult(status->{ + jdbc.update("INSERT INTO business_fact(kind) VALUES('CUSTOMS_COMPLETED')"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization(){ + @Override public void afterCommit(){ + try{auto.confirmIfReady(22L);}catch(RuntimeException failure){callbackFailure.set(failure);} + } + }); + }); + assertThat(callbackFailure.get()).isInstanceOf(BizException.class).hasMessage("carrier task insert failed"); + assertThat(jdbc.queryForList("SELECT kind FROM business_fact",String.class)).containsExactly("CUSTOMS_COMPLETED"); + rejectCarrier.set(false); + assertThat(auto.confirmIfReady(22L)).isTrue(); + assertThat(jdbc.queryForList("SELECT kind FROM business_fact",String.class)) + .containsExactlyInAnyOrder("CUSTOMS_COMPLETED","OUTBOUND","CARRIER_QUERY"); + assertThat(auto.confirmIfReady(22L)).isTrue(); + verify(dispatch,times(2)).confirm(anyLong(),anyLong(),anyString(),anyString(),any()); + } private static Detail detail(boolean gate,String status,int version){var result=new ContainerGateResult(gate,List.of(),LocalDateTime.now());var aggregate=new Aggregate(22L,"CAB","C",44L,"COMPLETED",66L,"RELEASED",77L,"DEPARTED",status,version,List.of(),result);return new Detail(aggregate,List.of());} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index c5b2d01d9..93ddbab9f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -4,6 +4,8 @@ import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; +import org.h2.jdbcx.JdbcDataSource; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; @@ -14,6 +16,34 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + @Test + void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception { + JdbcDataSource database = new JdbcDataSource(); + database.setURL("jdbc:h2:mem:carrier-scope-" + java.util.UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + JdbcTemplate jdbc = new JdbcTemplate(database); + jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT PRIMARY KEY,customer_id BIGINT)"); + jdbc.update("INSERT INTO fl_ocean_booking(id,customer_id) VALUES(1,NULL),(2,10001),(3,10002)"); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + for (String source : List.of(moduleSql(), unified)) { + int triggerStart = source.indexOf("CREATE TRIGGER trg_carrier_schedule_query_before_insert"); + int start = source.indexOf("WHERE booking.id=NEW.ocean_booking_id", triggerStart); + int end = source.indexOf(") THEN", start); + assertThat(triggerStart).isGreaterThanOrEqualTo(0); + assertThat(start).isGreaterThan(triggerStart); + assertThat(end).isGreaterThan(start); + String predicate = source.substring(start, end) + .replace("NEW.ocean_booking_id", "?").replace("NEW.customer_id", "?"); + String query = "SELECT EXISTS(SELECT 1 FROM fl_ocean_booking booking " + predicate + ")"; + assertThat(jdbc.queryForObject(query, Boolean.class, 1L, 0L)).isTrue(); + assertThat(jdbc.queryForObject(query, Boolean.class, 1L, 10001L)).isFalse(); + assertThat(jdbc.queryForObject(query, Boolean.class, 2L, 10001L)).isTrue(); + assertThat(jdbc.queryForObject(query, Boolean.class, 2L, 0L)).isFalse(); + assertThat(jdbc.queryForObject(query, Boolean.class, 2L, 10002L)).isFalse(); + assertThat(jdbc.queryForObject(query, Boolean.class, 99L, 0L)).isFalse(); + } + } + @Test void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception { String sql = String.join("\n", CarrierScheduleQueryMapper.class @@ -95,7 +125,7 @@ class CarrierScheduleQuerySqlContractTest { @Test void enforcesRealBookingContainerAndPermissions() throws Exception { assertThat(moduleSql()) - .contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id") + .contains("booking.id=NEW.ocean_booking_id AND COALESCE(booking.customer_id,0)=NEW.customer_id") .contains("allocation.container_booking_id=NEW.container_booking_id") .contains("id IN (5102184,5102186,5102188)") .contains("platform:firstleg:port-schedule-query:manual") -- 2.54.0 From 80632ba409e63582c6286ce0a4cd89633c31fce0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:58:15 +0800 Subject: [PATCH 027/176] test: strengthen customs and fulfillment acceptance boundaries --- scripts/README.md | 2 + scripts/admin-test-env-modules/README.md | 4 +- ...ainer-customs-acceptance-contract.test.mjs | 127 ++++++ ...nt-qualification-fixture-contract.test.mjs | 26 ++ .../merchant-qualification-fixture.mjs | 14 +- .../phase3-container-customs-fixture.mjs | 157 ++++++- .../phase3-container-customs.mjs | 226 ++++++++++- .../phase3-loading-marker-contract.test.mjs | 29 +- .../admin-test-env-modules/phase3-loading.mjs | 21 +- ...se5-overseas-fulfillment-contract.test.mjs | 191 ++++++++- .../phase5-overseas-fulfillment.mjs | 382 +++++++++++++++++- scripts/event-delivery-fault-proxy.py | 296 ++++++++++++++ scripts/event-delivery-fault-proxy.test.mjs | 181 +++++++++ 13 files changed, 1616 insertions(+), 40 deletions(-) create mode 100644 scripts/event-delivery-fault-proxy.py create mode 100644 scripts/event-delivery-fault-proxy.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 21641aa81..8b892e876 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id` 或 `--loading-plan-id`;`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 + `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 `db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4b97b0a51..cc8c75213 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -167,7 +167,7 @@ export async function run(context) { | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | -| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | +| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式客户资格、订单、测量、同柜装载和最终完成 API 生成平台公共订舱下的买单与两客户退税混合柜。第二客户独立登录,凭据只在内存;验证确认/打回隔离、正式申报各类人工异常及剩余 OPEN 门禁、每客户完成文件、自动出仓和唯一船期任务。客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | | 国内装柜验收作业员选择 | `domestic-pda-operator.mjs`;按本次 PDA 登录 Token 的字符串 subject 匹配目标仓库候选 userId,缺少匹配即停止,不使用首位候选或成员编号 | `domestic-pda-operator-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` | @@ -224,6 +224,8 @@ node scripts/admin-test-env-acceptance.mjs --gateway --modules ph 海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 +该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。 + ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment ``` diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index e96684265..bc499ab73 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs' import { CONTAINER_CUSTOMS_FIXTURE_KINDS, @@ -92,3 +93,129 @@ test('database evidence list covers atomicity, history, dispatch and outbox with 'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table) assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i) }) + +function runtimeContext() { + return { marker: 'OFFLINE-CUSTOMS', asserted: [], + assert(_module, name, _response, passed) { assert.ok(passed, name); this.asserted.push(name) }, + addCoverageGap() {}, addStep() {}, + } +} +const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } }) + +function formalAdapter({ prematurelyClose = false, invalidOtherWrites = false } = {}) { + const container = { loadingPlanId: '1', containerBookingId: '2', containerNo: 'TESTU1234567', + status: 'DECLARING', version: 8, brokerSubmittedAt: '2026-09-30T10:00:00', + documents: [{ documentId: '10', source: { fileId: '11' }, receiptStatus: 'CONFIRMED' }], inbounds: [] } + let batch = null + return { + platformDetail: async () => response(container), + platformCreateDeclaration: async () => { batch = { id: '100', status: 'READY', version: 0, results: [] }; return response(batch) }, + platformDeclarationBatches: async () => response([batch]), + platformSubmitDeclaration: async (_ctx, _fixture, _id, request) => { + assert.equal(request.expectedVersion, batch.version) + batch.status = 'SUBMITTED'; batch.version += 1; batch.customsEntryNo = request.customsEntryNo + return response(batch) + }, + platformRecordClearance: async (_ctx, _fixture, _id, request) => { + assert.equal(request.expectedVersion, batch.version) + if (request.resultType === 'OTHER' && !request.resolution) { + if (invalidOtherWrites) batch.results.push({ id: 'invalid', resultStatus: 'OPEN' }) + return response(null, 'A0400') + } + const result = { id: String(200 + batch.results.length), resultType: request.resultType, + resultStatus: request.resolved ? 'RESOLVED' : 'OPEN' } + batch.results.push(result) + batch.status = prematurelyClose && request.resolved ? 'CLOSED' + : batch.results.some((item) => item.resultStatus === 'OPEN') ? 'RESULT_PENDING' : 'CLOSED' + batch.version += 1 + return response(result) + }, + platformResolveClearance: async (_ctx, _fixture, resultId, request) => { + assert.equal(request.expectedVersion, batch.version) + const result = batch.results.find((item) => item.id === resultId) + result.resultStatus = 'RESOLVED'; batch.version += 1 + batch.status = batch.results.some((item) => item.resultStatus === 'OPEN') ? 'RESULT_PENDING' : 'CLOSED' + return response(result) + }, + platformClearanceResults: async () => response(batch.results), + } +} + +test('formal declaration runs every manual exception, retains OPEN gates and keeps customs document facts', async () => { + const ctx = runtimeContext(); const fixture = {} + await verifyFormalDeclarationAndExceptions(ctx, formalAdapter(), fixture) + assert.equal(fixture.declarationBatchId, '100') + assert.equal(fixture.clearanceResultIds.length, 5) + for (const type of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) { + assert.ok(ctx.asserted.includes(`平台人工标记海关异常${type}`)) + } + assert.ok(ctx.asserted.includes('海关人工处理保留客户资料回执确认原柜与报关四态')) +}) + +test('formal probe fails when a resolved result closes a batch with unresolved exceptions', async () => { + await assert.rejects(verifyFormalDeclarationAndExceptions(runtimeContext(), + formalAdapter({ prematurelyClose: true }), {}), /不能提前关闭申报批次/) +}) + +test('OTHER missing remark probe rejects hidden writes even when API returns expected error', async () => { + await assert.rejects(verifyFormalDeclarationAndExceptions(runtimeContext(), + formalAdapter({ invalidOtherWrites: true }), {}), /必须填写备注且批次结果零漂移/) +}) + +function mixedAdapter({ contaminateForeignConfirmation = false } = {}) { + const docs = [{ documentId: 'A', receipt: { version: 1, fileId: 'RA' }, receiptStatus: 'PENDING' }, + { documentId: 'B', receipt: null, receiptStatus: null }] + const container = { version: 2, status: 'DECLARING', documents: docs, brokerSubmittedAt: null } + const confirmations = new Set() + return { + platformDetail: async () => response(container), + platformUploadReceipt: async (_ctx, _fixture, request) => { + const doc = docs.find((item) => item.documentId === request.documentId) + assert.equal(request.expectedVersion, doc.receipt?.version || 0) + doc.receipt = { version: (doc.receipt?.version || 0) + 1, fileId: `R${doc.documentId}` } + doc.receiptStatus = 'PENDING' + return response(doc) + }, + merchantConfirmReceipt: async (_ctx, _fixture, customer, request, commandKey) => { + if (customer.documentId !== request.documentId) { + if (contaminateForeignConfirmation) docs[0].receiptStatus = 'PENDING' + return response(null, 'A0404') + } + const doc = docs.find((item) => item.documentId === request.documentId) + assert.equal(request.expectedVersion, doc.receipt.version) + if (!confirmations.has(commandKey)) { + doc.receiptStatus = request.confirm ? 'CONFIRMED' : 'REJECTED' + confirmations.add(commandKey) + } + return response(container) + }, + platformSubmitToBroker: async () => response(null, 'A0409'), + platformCreateDeclaration: async () => response(null, 'A0409'), + } +} +const mixedFixture = () => ({ customers: [{ documentId: 'A', customerId: '1' }, + { documentId: 'B', customerId: '2' }] }) + +test('multi-customer receipt flow rejects, replaces and confirms only the affected customer', async () => { + const ctx = runtimeContext() + const result = await verifyMixedReceiptIsolation(ctx, mixedAdapter(), mixedFixture(), + response({ receipt: { version: 1 } })) + assert.ok(result.body.data.documents.every((item) => item.receiptStatus === 'CONFIRMED')) + assert.ok(ctx.asserted.includes('未全部客户确认禁止发送报关行执行')) + assert.ok(ctx.asserted.includes('未全部确认禁止创建正式申报批次')) +}) + +test('cross-customer receipt denial also requires prior confirmation remain unchanged', async () => { + await assert.rejects(verifyMixedReceiptIsolation(runtimeContext(), + mixedAdapter({ contaminateForeignConfirmation: true }), mixedFixture(), + response({ receipt: { version: 1 } })), /第二客户不能确认其他客户回执且原确认保持/) +}) + +test('second customer credentials stay in adapter memory and are not exported with report resources', () => { + assert.match(fixtureSource, /publishEnvironment: false/) + assert.match(fixtureSource, /customerTokens\.get/) + assert.match(fixtureSource, /customers\.length < 2/) + const customerData = fixtureSource.slice(fixtureSource.indexOf('const customers ='), fixtureSource.indexOf('const buyInboundIds =')) + assert.doesNotMatch(customerData, /(?:sessionToken|accessToken|customerToken|password)/) + assert.doesNotMatch(fixtureSource, /\b(?:INSERT\s+INTO|DELETE\s+FROM|UPDATE\s+(?:fl_|ll_|wh_|ord_))/i) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index bda5d37e9..636c72864 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -1,11 +1,37 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { qualificationCreditCode } from './merchant-qualification-fixture.mjs' const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') +test('credit codes distinguish repeated CUSTOMER-B and SIGNED-B fixtures using the complete marker', () => { + const customerId = '2100000000000000001' + const values = [] + for (const ending of ['CUSTOMER-B', 'SIGNED-B']) { + const firstMarker = `ADMIN-API-20260930121500000-${ending}` + const secondMarker = `ADMIN-API-20260930121600000-${ending}` + const first = qualificationCreditCode(firstMarker, customerId) + const second = qualificationCreditCode(secondMarker, customerId) + assert.notEqual(first, second, `时间不同但后缀同为 ${ending} 必须生成不同代码`) + assert.equal(qualificationCreditCode(firstMarker, customerId), first) + for (const value of [first, second]) assert.match(value, /^91440300[0-9A-F]{10}$/) + values.push(first, second) + } + assert.equal(new Set(values).size, values.length) + assert.match(fixture, /unifiedSocialCreditCode = qualificationCreditCode\(context\.marker, tenantId\)/) +}) + +test('a new real customer under the same marker receives a distinct stable code', () => { + const marker = 'ADMIN-API-20260930121500000-CUSTOMER-B' + assert.notEqual(qualificationCreditCode(marker, '2100000000000000001'), + qualificationCreditCode(marker, '2100000000000000002')) + assert.throws(() => qualificationCreditCode('', '2100000000000000001'), /完整批次标识/) + assert.throws(() => qualificationCreditCode(marker, ''), /真实客户编号/) +}) + test('默认全量验收先准备已审核商户再运行订单链路', () => { const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' assert.match(modules, /^'merchant-qualification-fixture', 'customers'/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index b754e0466..07fa6f343 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + const MODULE = 'merchant-qualification-fixture' const REQUIRED_TYPES = new Set(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID']) const QUALIFICATION_IMAGE = Uint8Array.from(Buffer.from( @@ -9,6 +11,16 @@ function id(value) { return value === undefined || value === null ? '' : String(value) } +export function qualificationCreditCode(marker, tenantId) { + const customerId = id(tenantId) + if (!String(marker || '').trim() || !customerId) { + throw new Error('测试资质代码必须使用完整批次标识和本轮真实客户编号') + } + const suffix = createHash('sha256').update(JSON.stringify([String(marker), customerId])) + .digest('hex').slice(0, 10).toUpperCase() + return `91440300${suffix}` +} + async function waitForDimensionInitialization(context, tenantId) { let response = null let status = 'PENDING' @@ -62,7 +74,7 @@ export async function run(context, { publishEnvironment = true } = {}) { const packageForm = new FormData() packageForm.append('businessLicense', new File([QUALIFICATION_IMAGE], `business-${suffix}.png`, { type: 'image/png' })) packageForm.append('legalPersonId', new File([QUALIFICATION_IMAGE], `legal-person-${suffix}.png`, { type: 'image/png' })) - const unifiedSocialCreditCode = `91440300${suffix}` + const unifiedSocialCreditCode = qualificationCreditCode(context.marker, tenantId) const packageResponse = await context.requestWithBody('POST', `/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`, packageForm) diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 0d92e3282..5507e1853 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -50,7 +50,8 @@ async function loginMerchant(context) { return token } -function routes(context, merchantToken) { +function routes(context, merchantToken, customerTokens = new Map()) { + const tokenFor = (customer) => customerTokens.get(id(customer.customerId)) || merchantToken const binary = (method, path, token = context.token) => raw(context, method, path, undefined, token) const multipart = (path, fields, file, token, idempotencyKey) => { const form = new FormData() @@ -64,27 +65,27 @@ function routes(context, merchantToken) { return { version: 1, platformDetail: (ctx, value) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(value.loadingPlanId))}`), - merchantDetail: (ctx, fixture, customer, token = merchantToken) => ctx.request('GET', + merchantDetail: (ctx, fixture, customer, token = tokenFor(customer)) => ctx.request('GET', `${MERCHANT}/orders/${encodeURIComponent(id(customer.orderId))}`, undefined, token), merchantPlatformDetail: (ctx, fixture) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}`, undefined, merchantToken), merchantUploadDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart( `${MERCHANT}/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/documents/upload?${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`, - {}, file, customer.customerToken || merchantToken, idempotencyKey), + {}, file, tokenFor(customer), idempotencyKey), merchantReplaceDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart( `${MERCHANT}/documents/${encodeURIComponent(id(file.documentId))}/upload?expectedVersion=${Number(file.expectedVersion)}&${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`, - {}, file, customer.customerToken || merchantToken, idempotencyKey), + {}, file, tokenFor(customer), idempotencyKey), merchantUpdateScope: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('PUT', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/inbounds`, { inboundIds: request.inboundIds.map(id), expectedVersion: Number(request.expectedVersion), - }, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + }, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`, - undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + undefined, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`, { expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' }, - customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), @@ -97,6 +98,22 @@ function routes(context, merchantToken) { platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), + platformDeclarationBatches: (ctx, fixture) => ctx.request('GET', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`), + platformCreateDeclaration: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformSubmitDeclaration: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/submit`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformClearanceResults: (ctx, fixture, batchId) => ctx.request('GET', + `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`), + platformRecordClearance: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformResolveClearance: (ctx, fixture, resultId, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/clearance-results/${encodeURIComponent(id(resultId))}/resolve`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), @@ -109,10 +126,10 @@ function routes(context, merchantToken) { `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`), merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`, - customer.customerToken || merchantToken), + tokenFor(customer)), merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`, - customer.customerToken || merchantToken), + tokenFor(customer)), buyDeclarationXlsx: async (ctx, request) => { const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', { exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', @@ -150,6 +167,98 @@ function routes(context, merchantToken) { } } +async function addSecondRefundCustomer(context) { + const source = context.createdResources.phase3Loading + const secondary = Object.create(context) + secondary.marker = `${context.marker}-CUSTOMER-B` + secondary.options = { ...context.options, customerName: '' } + secondary.context = { ...context.context, orderProfile: { + ...(context.context.orderProfile || {}), taxMode: 'TAX_REFUND', fulfillmentType: 'STANDARD', + }, orderFulfillmentType: 'STANDARD' } + secondary.createdResources = {} + const qualification = await import('./merchant-qualification-fixture.mjs') + await qualification.run(secondary, { publishEnvironment: false }) + const account = secondary.context.merchantQualificationFixture + // orders.mjs captures its credentials at import time. A distinct module instance captures + // this customer's credentials without changing the primary customer's cached producer. + const saved = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD'].map((name) => + [name, Object.hasOwn(process.env, name), process.env[name]]) + let orders + try { + process.env.MERCHANT_TEST_USERNAME = account.username + process.env.MERCHANT_TEST_PASSWORD = account.password + orders = await import(`./orders.mjs?customsCustomer=${encodeURIComponent(account.tenantId)}`) + } finally { + for (const [name, present, value] of saved) { + if (present) process.env[name] = value + else delete process.env[name] + } + } + await secondary.executeModule(`${MODULE}-customer-b-orders`, () => orders.run(secondary)) + const measurement = await import('./inbound-measurement.mjs') + await secondary.executeModule(`${MODULE}-customer-b-measurement`, () => measurement.run(secondary)) + const inbound = secondary.createdResources.inboundMeasurement + if (!inbound?.inboundId || id(inbound.tenantId) !== id(account.tenantId) + || id(inbound.tenantId) === id(source.tenantId)) throw new Error('第二退税客户未生成独立入库单') + const login = await secondary.request('POST', '/api/auth/login', { + loginId: account.username, password: account.password, + }, '') + if (!success(login) || !login.body?.data?.accessToken) throw new Error('第二退税客户登录失败') + const smart = '/api/platform/v1/first-leg/smart-loading' + const candidatesResponse = await context.request('GET', `${smart}/candidates?${new URLSearchParams({ + bookingId: id(source.containerBookingId), warehouseId: id(source.warehouseId), + })}`) + const candidates = context.apiSuccess(MODULE, '第二退税客户读取原柜正式可装候选', candidatesResponse, '', OPTIONS) + const candidate = (Array.isArray(candidates) ? candidates : candidates?.records || []).find((item) => + id(item.inboundId) === id(inbound.inboundId) && id(item.accountId) === id(account.tenantId) + && item.inboundDetailId && Number(item.availablePackageCount) > 0) + if (!candidate) throw new Error('第二退税客户在原柜没有正式可装候选,禁止借历史票') + const planResponse = await context.request('GET', `${smart}/plans/${id(source.loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '加入第二客户前回查原柜计划', planResponse, '', OPTIONS) + const assignedResponse = await context.request('POST', `${smart}/plans/${id(source.loadingPlanId)}/assignments`, { + inboundId: id(inbound.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(plan.version), operationId: key(context, 'customer-b-assign'), + }) + const assigned = context.apiSuccess(MODULE, '第二退税客户正式分配到同柜', assignedResponse, '', OPTIONS) + const item = assigned?.items?.find((value) => id(value.inboundId) === id(inbound.inboundId)) + const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${id(source.canonicalPdaTaskId)}` + const taskResponse = await context.request('GET', `${taskPath}?warehouseId=${id(source.warehouseId)}`) + const task = context.apiSuccess(MODULE, '同柜新增第二客户后回查原PDA任务', taskResponse, '', OPTIONS) + const taskItem = task?.items?.find((value) => id(value.loadingPlanItemId) === id(item?.id)) + const inventoryResponse = await context.request('GET', `${taskPath}/inventory-snapshot?warehouseId=${id(source.warehouseId)}`) + const inventory = context.apiSuccess(MODULE, '第二客户刷新正式拣货库存快照', inventoryResponse, '', OPTIONS) + if (!taskItem || !inventory?.snapshotToken) throw new Error('第二客户缺少正式PDA明细或库存快照') + const pickedResponse = await context.request('POST', `${taskPath}/batch`, { + warehouseId: id(source.warehouseId), loadingPlanId: id(source.loadingPlanId), + containerBookingId: id(source.containerBookingId), expectedVersion: Number(task.version), + inventorySnapshotToken: inventory.snapshotToken, + items: [{ itemId: id(taskItem.id), action: 'PICK', pickedPackageCount: 1, version: Number(taskItem.version) }], + }, context.pdaToken || context.token, { 'Idempotency-Key': key(context, 'customer-b-pick') }) + const picked = context.apiSuccess(MODULE, '同柜第二客户完成正式PDA拣货', pickedResponse, '', OPTIONS) + context.assert(MODULE, '第二客户加入后原柜原任务保持且恢复待完成', pickedResponse, + id(picked?.id) === id(source.canonicalPdaTaskId) && picked?.workflowStatus === 'AWAITING_REVIEW' + && id(picked?.loadingPlanId) === id(source.loadingPlanId) + && id(picked?.containerBookingId) === id(source.containerBookingId), + `workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS) + source.pdaTaskVersion = Number(picked?.version) + const { stableLoadedCartonMarks } = await import('./phase3-loading.mjs') + source.cartonMarks = stableLoadedCartonMarks(picked?.items) + return { customerId: id(account.tenantId), sessionToken: login.body.data.accessToken } +} + +export async function withPlatformBookingScope(context, runLoading) { + const name = 'phase3LoadingBookingScope' + const present = Object.hasOwn(context.context, name) + const previous = context.context[name] + context.context[name] = 'PLATFORM' + try { + return await runLoading() + } finally { + if (present) context.context[name] = previous + else delete context.context[name] + } +} + export async function prepare(context) { const outerMarker = context.marker const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] @@ -165,6 +274,7 @@ export async function prepare(context) { const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, { primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND', }) + try { const executionMarker = `${outerMarker}-CUSTOMS-MIXED` context.marker = executionMarker if (context.createdResources?.orchestrationFixtures?.phase3Loading) { @@ -174,7 +284,9 @@ export async function prepare(context) { context.createdResources.orchestrationFixtures.phase3LoadingIncremental.marker = executionMarker } const loading = await import('./phase3-loading.mjs') - await context.executeModule(`${MODULE}-loading`, () => loading.run(context)) + await withPlatformBookingScope(context, () => + context.executeModule(`${MODULE}-loading`, () => loading.run(context))) + const secondCustomer = await addSecondRefundCustomer(context) const finalize = await import('./phase3-loading-finalize.mjs') await context.executeModule(`${MODULE}-finalize`, () => finalize.run(context)) @@ -202,10 +314,11 @@ export async function prepare(context) { throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`) } const merchantToken = await loginMerchant(context) - const customerId = id(refund[0].tenantId) - const customer = { - customerId, orderId: id(refund[0].orderId), - inboundIds: refund.map((item) => id(item.inboundId)), + const customers = [...new Set(refund.map((item) => id(item.tenantId)))].map((customerId) => { + const own = refund.filter((item) => id(item.tenantId) === customerId) + return { + customerId, orderId: id(own[0].orderId), + inboundIds: own.map((item) => id(item.inboundId)), document: { fileName: `${context.marker}-tax-refund.customblob`, contentType: 'application/octet-stream', content: Uint8Array.from([0x54, 0x41, 0x58]), @@ -214,8 +327,9 @@ export async function prepare(context) { fileName: `${context.marker}-tax-refund-v2.customblob`, contentType: 'application/octet-stream', content: Uint8Array.from([0x54, 0x41, 0x58, 0x32]), }, - updatedInboundIds: refund.map((item) => id(item.inboundId)), - } + updatedInboundIds: own.map((item) => id(item.inboundId)), + } }) + if (customers.length < 2) throw new Error('同柜未形成两个实际退税客户') const buyInboundIds = new Set(buy.map((item) => id(item.inboundId))) const planBuyRows = planItems.filter((item) => buyInboundIds.has(id(item.inboundId))).length const expectedBuyRows = planBuyRows > 0 ? planBuyRows : buy.length @@ -230,14 +344,21 @@ export async function prepare(context) { inboundId: id(item.inboundId), orderId: id(item.orderId), customerId: id(item.tenantId), inboundCode: item.inboundCode, taxMode: item.taxMode, })), - customers: [customer], expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status, + customers, expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status, }], } - context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken) + context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken, new Map([[secondCustomer.customerId, secondCustomer.sessionToken]])) return () => { delete context.context.phase3ContainerCustomsRoutes context.marker = outerMarker restore() restoreIsolatedResources() } + } catch (error) { + delete context.context.phase3ContainerCustomsRoutes + context.marker = outerMarker + restore() + restoreIsolatedResources() + throw error + } } diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index 108fbc619..c51dabad5 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -33,6 +33,11 @@ function resolvePreparation(context) { if (!validateRouteContract(routes)) { blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求') } + if (requestedKinds.includes('MIXED') && ['platformDeclarationBatches', 'platformCreateDeclaration', + 'platformSubmitDeclaration', 'platformClearanceResults', 'platformRecordClearance', + 'platformResolveClearance'].some((name) => typeof routes[name] !== 'function')) { + blocker(context, '正式申报与人工海关结果HTTP契约', '缺少已核对Controller的正式申报或海关结果适配器') + } return { fixtures, routes, requestedKinds } } @@ -260,6 +265,168 @@ async function runRefund(context, routes, fixture) { } +export function customsDocumentFacts(container) { + return { loadingPlanId: id(container?.loadingPlanId), containerBookingId: id(container?.containerBookingId), + containerNo: container?.containerNo, status: container?.status, + documents: container?.documents || [], inbounds: container?.inbounds || [], + brokerSubmittedAt: container?.brokerSubmittedAt, brokerSubmission: container?.brokerSubmission } +} + +export async function verifyMixedReceiptIsolation(context, routes, fixture, firstReceipt) { + const [first, other] = fixture.customers + const receiptVersion = Number(firstReceipt.body?.data?.receipt?.version) + const firstConfirmed = await routes.merchantConfirmReceipt(context, fixture, first, { + documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + const firstReplay = await routes.merchantConfirmReceipt(context, fixture, first, { + documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + context.assert(MODULE, '客户确认回执后仍未完成整柜', firstReplay, + success(firstConfirmed) && success(firstReplay) && firstReplay.body?.data?.status === 'DECLARING', + evidence(firstReplay), OPTIONS) + const beforeOther = await platformDetail(context, routes, fixture) + const confirmedFirst = beforeOther.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId) + for (const customer of fixture.customers.slice(1)) { + const uploaded = await routes.platformUploadReceipt(context, fixture, { + documentId: customer.documentId, expectedVersion: 0, + fileName: `${context.marker}-${customer.customerId}-receipt.customblob`, + contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x32]), + }, key(context, `receipt-${customer.customerId}`)) + context.assert(MODULE, '第二客户报关回执独立上传', uploaded, success(uploaded), evidence(uploaded), OPTIONS) + } + const foreignConfirm = await routes.merchantConfirmReceipt(context, fixture, other, { + documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'foreign-receipt-confirm')) + const foreignBefore = await platformDetail(context, routes, fixture) + context.assert(MODULE, '第二客户不能确认其他客户回执且原确认保持', foreignConfirm, + foreignConfirm.http === 200 && ['A0403', 'A0404'].includes(code(foreignConfirm)) + && zeroDrift(confirmedFirst, foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)), + evidence(foreignConfirm), OPTIONS) + const otherDocument = foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId) + const rejected = await routes.merchantConfirmReceipt(context, fixture, other, { + documentId: other.documentId, expectedVersion: Number(otherDocument?.receipt?.version), + confirm: false, remark: `${context.marker}-回执资料需修改`, + }, key(context, 'mixed-other-reject')) + const afterReject = await platformDetail(context, routes, fixture) + const rejectedDocument = afterReject.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId) + context.assert(MODULE, '第二客户打回不撤销首个客户整份确认', rejected, + success(rejected) && rejectedDocument?.receiptStatus === 'REJECTED' + && zeroDrift(confirmedFirst, afterReject.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)), + evidence(rejected), OPTIONS) + const earlyExecution = await routes.platformSubmitToBroker(context, fixture, { + expectedVersion: Number(afterReject.body?.data?.version), confirm: true, remark: context.marker, + }, key(context, 'mixed-unconfirmed-execution')) + await assertZeroDrift(context, '未全部客户确认禁止发送报关行执行', routes, fixture, afterReject, earlyExecution) + const earlyDeclaration = await routes.platformCreateDeclaration(context, fixture, { + expectedVersion: Number(afterReject.body?.data?.version), remark: context.marker, + }, key(context, 'mixed-unconfirmed-declaration')) + await assertZeroDrift(context, '未全部确认禁止创建正式申报批次', routes, fixture, afterReject, earlyDeclaration) + const replacement = await routes.platformUploadReceipt(context, fixture, { + documentId: other.documentId, expectedVersion: Number(rejectedDocument?.receipt?.version), + fileName: `${context.marker}-${other.customerId}-receipt-v2.customblob`, + contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x33]), + }, key(context, 'mixed-other-receipt-replacement')) + const afterReplacement = await platformDetail(context, routes, fixture) + context.assert(MODULE, '替换打回客户回执只重置该客户待确认且保留他人确认', replacement, + success(replacement) && replacement.body?.data?.receiptStatus === 'PENDING' + && zeroDrift(confirmedFirst, afterReplacement.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)), + evidence(replacement), OPTIONS) + for (const customer of fixture.customers.slice(1)) { + const current = await platformDetail(context, routes, fixture) + const document = current.body?.data?.documents?.find((item) => id(item.documentId) === customer.documentId) + const confirmation = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: customer.documentId, expectedVersion: Number(document?.receipt?.version), + confirm: true, remark: context.marker, + }, key(context, `mixed-other-confirm-${customer.customerId}`)) + context.assert(MODULE, '剩余客户确认自己当前整份回执', confirmation, success(confirmation), evidence(confirmation), OPTIONS) + } + const confirmed = await platformDetail(context, routes, fixture) + context.assert(MODULE, '同柜全部客户确认齐全仍等待平台发送执行', confirmed, + success(confirmed) && confirmed.body?.data?.documents?.every((item) => item.receiptStatus === 'CONFIRMED') + && confirmed.body?.data?.brokerSubmittedAt == null && confirmed.body?.data?.status === 'DECLARING', + evidence(confirmed), OPTIONS) + return confirmed +} + +export async function verifyFormalDeclarationAndExceptions(context, routes, fixture) { + const before = await platformDetail(context, routes, fixture) + const baseline = customsDocumentFacts(before.body?.data) + const created = await routes.platformCreateDeclaration(context, fixture, { + expectedVersion: Number(before.body?.data?.version), batchNo: `${context.marker}-DECL`.slice(-64), remark: context.marker, + }, key(context, 'formal-create')) + context.assert(MODULE, '平台人工创建正式申报批次', created, + success(created) && created.body?.data?.status === 'READY', evidence(created), OPTIONS) + if (!success(created)) blocker(context, '正式申报批次', '创建正式申报批次失败,保留资料并停止结果写入') + const batchId = id(created.body.data.id) + const batches = () => routes.platformDeclarationBatches(context, fixture) + const batch = async () => { + const response = await batches() + if (!success(response)) blocker(context, '正式申报批次回查', evidence(response)) + const value = response.body.data.find((item) => id(item.id) === batchId) + if (!value) blocker(context, '正式申报批次回查', '本轮批次不在当前柜列表') + return value + } + const submitted = await routes.platformSubmitDeclaration(context, fixture, batchId, { + expectedVersion: Number(created.body.data.version), customsEntryNo: `${context.marker}-ENTRY`.slice(-64), remark: context.marker, + }, key(context, 'formal-submit')) + context.assert(MODULE, '人工回填报关单号提交正式申报', submitted, + success(submitted) && submitted.body?.data?.status === 'SUBMITTED' + && Boolean(submitted.body?.data?.customsEntryNo), evidence(submitted), OPTIONS) + if (!success(submitted)) blocker(context, '正式申报提交', '正式提交失败,禁止继续人工录入海关结果') + const local = new Date() + const occurredAt = `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}T${String(local.getHours()).padStart(2, '0')}:${String(local.getMinutes()).padStart(2, '0')}:${String(local.getSeconds()).padStart(2, '0')}` + const missingRemarkBatch = await batch() + const invalidOther = await routes.platformRecordClearance(context, fixture, batchId, { + expectedVersion: Number(missingRemarkBatch.version), resultType: 'OTHER', occurredAt, resolution: '', resolved: false, + }, key(context, 'other-missing-remark')) + const afterInvalid = await batch() + context.assert(MODULE, '海关其他异常必须填写备注且批次结果零漂移', invalidOther, + invalidOther.http === 200 && code(invalidOther) === 'A0400' && zeroDrift(missingRemarkBatch, afterInvalid), + evidence(invalidOther), OPTIONS) + const opened = [] + for (const resultType of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) { + const current = await batch() + const recorded = await routes.platformRecordClearance(context, fixture, batchId, { + expectedVersion: Number(current.version), resultType, occurredAt, + resolution: `${context.marker}-${resultType}-人工记录`, resolved: false, + }, key(context, `clearance-${resultType}`)) + context.assert(MODULE, `平台人工标记海关异常${resultType}`, recorded, + success(recorded) && recorded.body?.data?.resultType === resultType && recorded.body?.data?.resultStatus === 'OPEN', + evidence(recorded), OPTIONS) + if (!success(recorded)) blocker(context, '人工海关异常', `${resultType}保存失败`) + opened.push(id(recorded.body.data.id)) + } + const pending = await batch() + const released = await routes.platformRecordClearance(context, fixture, batchId, { + expectedVersion: Number(pending.version), resultType: 'RELEASED', occurredAt, + resolution: `${context.marker}-人工录入放行短信`, resolved: true, + }, key(context, 'clearance-released-with-open')) + const stillPending = await batch() + context.assert(MODULE, '已有未解除异常时新增已处理结果不能提前关闭申报批次', released, + success(released) && stillPending.status === 'RESULT_PENDING' + && stillPending.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length, + evidence(released), OPTIONS) + for (const [index, resultId] of opened.entries()) { + const current = await batch() + const resolved = await routes.platformResolveClearance(context, fixture, resultId, { + expectedVersion: Number(current.version), resolution: `${context.marker}-平台人工确认解除`, + }, key(context, `clearance-resolve-${resultId}`)) + const after = await batch() + context.assert(MODULE, `人工解除海关异常并保留剩余门禁${index + 1}`, resolved, + success(resolved) && resolved.body?.data?.resultStatus === 'RESOLVED' + && after.status === (index === opened.length - 1 ? 'CLOSED' : 'RESULT_PENDING') + && after.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length - index - 1, + evidence(resolved), OPTIONS) + } + const results = await routes.platformClearanceResults(context, fixture, batchId) + const after = await platformDetail(context, routes, fixture) + context.assert(MODULE, '海关人工处理保留客户资料回执确认原柜与报关四态', after, + success(results) && results.body?.data?.length === 5 && results.body.data.every((item) => item.resultStatus === 'RESOLVED') + && success(after) && zeroDrift(baseline, customsDocumentFacts(after.body?.data)), evidence(after), OPTIONS) + fixture.declarationBatchId = batchId + fixture.clearanceResultIds = [...opened, id(released.body?.data?.id)] +} + async function runMixedConcurrency(context, routes, fixture) { const initial = await platformDetail(context, routes, fixture) const initialData = initial.body?.data @@ -278,7 +445,7 @@ async function runMixedConcurrency(context, routes, fixture) { if (!customer) blocker(context, '混合柜客户fixture', '缺少本轮退税客户与正式登录凭据') const merchant = await routes.merchantDetail(context, fixture, customer, customer.customerToken) context.assert(MODULE, 'Client只见本客户TAX票且完全隐藏BUY票', merchant, - success(merchant) && merchant.body?.data?.inbounds?.length === refund.length + success(merchant) && merchant.body?.data?.inbounds?.length === customer.inboundIds.length && merchant.body.data.inbounds.every((item) => item.taxMode === 'TAX_REFUND') && !merchant.body.data.inbounds.some((item) => fixture.buyInboundIds.includes(id(item.inboundId))), evidence(merchant), OPTIONS) @@ -303,7 +470,7 @@ async function runMixedConcurrency(context, routes, fixture) { const uploadKey = key(context, 'mixed-tax-upload') const uploaded = await routes.merchantUploadDocument(context, fixture, customer, customer.document, uploadKey) context.assert(MODULE, 'Client上传资料只覆盖本柜TAX票', uploaded, - success(uploaded) && uploaded.body?.data?.inboundIds?.length === refund.length + success(uploaded) && uploaded.body?.data?.inboundIds?.length === customer.inboundIds.length && uploaded.body.data.inboundIds.every((value) => customer.inboundIds.includes(id(value))) && !uploaded.body.data.inboundIds.some((value) => fixture.buyInboundIds.includes(id(value))), evidence(uploaded), OPTIONS) @@ -314,6 +481,21 @@ async function runMixedConcurrency(context, routes, fixture) { customer.documentId = id(uploaded.body?.data?.documentId) customer.documentVersion = Number(uploaded.body?.data?.source?.version) + if (fixture.customers.length < 2) blocker(context, '同柜多退税客户', '必须由本轮正式装柜形成两个独立退税客户') + for (const other of fixture.customers.slice(1)) { + const isolated = await routes.merchantDetail(context, fixture, other) + context.assert(MODULE, '第二客户只见自身退税入仓票', isolated, + success(isolated) && isolated.body?.data?.inbounds?.length === other.inboundIds.length + && isolated.body.data.inbounds.every((item) => other.inboundIds.includes(id(item.inboundId))), + evidence(isolated), OPTIONS) + const otherUpload = await routes.merchantUploadDocument(context, fixture, other, other.document, + key(context, `mixed-tax-upload-${other.customerId}`)) + context.assert(MODULE, '第二退税客户独立上传资料', otherUpload, + success(otherUpload) && zeroDrift(otherUpload.body?.data?.inboundIds, other.inboundIds), evidence(otherUpload), OPTIONS) + if (!success(otherUpload)) blocker(context, '第二客户退税资料', '本轮第二客户资料上传失败,禁止继续开始') + other.documentId = id(otherUpload.body.data.documentId) + } + const ready = await platformDetail(context, routes, fixture) context.assert(MODULE, 'TAX票全部覆盖后平台准确进入READY_TO_START', ready, success(ready) && ready.body?.data?.status === 'READY_TO_START' @@ -372,22 +554,12 @@ async function runMixedConcurrency(context, routes, fixture) { const receiptDifferent = await routes.platformUploadReceipt(context, fixture, { ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey) context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS) - const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0) - const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { - documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, - }, key(context, 'mixed-receipt-confirm')) - const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { - documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, - }, key(context, 'mixed-receipt-confirm')) - context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm, - success(receiptConfirm) && success(receiptConfirmReplay) - && receiptConfirm.body?.data?.status === 'DECLARING' - && receiptConfirmReplay.body?.data?.status === 'DECLARING', - [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS) + const receiptConfirm = await verifyMixedReceiptIsolation(context, routes, fixture, receipt) const submitted = await routes.platformSubmitToBroker(context, fixture, { expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker')) context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted, success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + await verifyFormalDeclarationAndExceptions(context, routes, fixture) const finalRequest = { documentId: customer.documentId, expectedVersion: 0, fileName: `${context.marker}-mixed-customs-tax-final.pdf`, @@ -408,6 +580,19 @@ async function runMixedConcurrency(context, routes, fixture) { { ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey) context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS) + const partiallyCompleted = await platformDetail(context, routes, fixture) + context.assert(MODULE, '首个客户完成文件不能提前完成多客户整柜', partiallyCompleted, + success(partiallyCompleted) && partiallyCompleted.body?.data?.status === 'DECLARING', evidence(partiallyCompleted), OPTIONS) + for (const other of fixture.customers.slice(1)) { + const otherFinal = await routes.platformUploadFinal(context, fixture, { + documentId: other.documentId, expectedVersion: 0, + fileName: `${context.marker}-${other.customerId}-final.customblob`, + contentType: 'application/octet-stream', content: Uint8Array.from([0x46, 0x32]), + }, key(context, `mixed-final-${other.customerId}`)) + context.assert(MODULE, '其他客户真实完成文件独立回传', otherFinal, + success(otherFinal) && zeroDrift(otherFinal.body?.data?.inboundIds, other.inboundIds), evidence(otherFinal), OPTIONS) + } + let completed = await platformDetail(context, routes, fixture) context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) @@ -451,6 +636,19 @@ async function runMixedConcurrency(context, routes, fixture) { context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload, completionDownload.http === 200 && completionDownload.bytes?.length > 0, `HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS) + for (const other of fixture.customers.slice(1)) { + const ownDetail = await routes.merchantDetail(context, fixture, other) + context.assert(MODULE, '整柜完成后第二客户详情只包含自身资料与完成文件', ownDetail, + success(ownDetail) && ownDetail.body?.data?.documents?.length === 1 + && ownDetail.body.data.documents.every((item) => id(item.tenantId) === other.customerId + && id(item.documentId) === other.documentId && item.finalDocument?.fileId), + evidence(ownDetail), OPTIONS) + const foreignDownload = await routes.merchantDownloadFinal(context, fixture, other, { + documentId: customer.documentId, + }) + context.assert(MODULE, '第二客户不能下载其他客户完成文件', foreignDownload, + foreignDownload.http === 200 && code(foreignDownload) === 'A0404', evidence(foreignDownload), OPTIONS) + } } export async function run(context) { diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs index ec85dd985..34b9ee4aa 100644 --- a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -1,7 +1,33 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { markerSuffix } from './phase3-loading.mjs' +import { loadingBookingCustomerFields, markerSuffix } from './phase3-loading.mjs' +import { withPlatformBookingScope } from './phase3-container-customs-fixture.mjs' + +test('normal loading keeps customer scope; explicit platform booking omits both customer fields', () => { + const fields = loadingBookingCustomerFields(undefined, '2100000000000000001', '测试客户') + assert.deepEqual(fields, { customerId: '2100000000000000001', customerName: '测试客户' }) + assert.deepEqual(loadingBookingCustomerFields('CUSTOMER', fields.customerId, fields.customerName), fields) + const platform = loadingBookingCustomerFields('PLATFORM', fields.customerId, fields.customerName) + assert.equal(Object.hasOwn(platform, 'customerId'), false) + assert.equal(Object.hasOwn(platform, 'customerName'), false) + assert.throws(() => loadingBookingCustomerFields('MISSPELLED', fields.customerId, fields.customerName), /订舱范围/) +}) + +test('mixed fixture platform scope restores absence and an existing value after success or failure', async () => { + const absent = { context: {} } + assert.equal(await withPlatformBookingScope(absent, async () => { + assert.equal(absent.context.phase3LoadingBookingScope, 'PLATFORM') + return 'loaded' + }), 'loaded') + assert.equal(Object.hasOwn(absent.context, 'phase3LoadingBookingScope'), false) + const existing = { context: { phase3LoadingBookingScope: 'CUSTOMER' } } + await assert.rejects(withPlatformBookingScope(existing, async () => { + assert.equal(existing.context.phase3LoadingBookingScope, 'PLATFORM') + throw new Error('actual loading failure') + }), /actual loading failure/) + assert.equal(existing.context.phase3LoadingBookingScope, 'CUSTOMER') +}) test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => { const base = 'ADMIN-API-20260824123024536' @@ -18,6 +44,7 @@ test('phase3 loading uses the collision-safe marker suffix for generated busines const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('./phase3-loading.mjs', import.meta.url), 'utf8')) assert.match(source, /const suffix = markerSuffix\(context\.marker\)/) + assert.match(source, /const bookingDraft = \{\s*\.\.\.loadingBookingCustomerFields\(context\.context\?\.phase3LoadingBookingScope, tenantId,/) assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/) assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/) assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 024401f5c..93cd2d6af 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -13,6 +13,11 @@ const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } +export function loadingBookingCustomerFields(scope = 'CUSTOMER', customerId, customerName) { + if (scope === 'PLATFORM') return {} + if (scope !== 'CUSTOMER') throw new Error('三期订舱范围必须明确为 CUSTOMER 或 PLATFORM') + return { customerId, customerName } +} export function markerSuffix(marker) { const value = String(marker || '') const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0') @@ -201,7 +206,8 @@ export async function run(context) { supplierId: id(shippingCarrier.id), name: shippingCarrier.name, status: 'ACTIVE', stableDevFixture: true, } const bookingDraft = { - customerId: tenantId, customerName: initial?.summary?.customerName, + ...loadingBookingCustomerFields(context.context?.phase3LoadingBookingScope, tenantId, + initial?.summary?.customerName), warehouseId, channelCode: channelGroup(initial?.summary?.channelCode), billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), @@ -569,6 +575,13 @@ export async function run(context) { { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) }) const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS) + if (context.context?.phase3LoadingBookingScope === 'PLATFORM') { + const platformScoped = bookingDraftResponse?.oceanBooking?.customerId == null + && bookingDraftResponse?.oceanBooking?.customerName == null + context.assert(MODULE, '多客户混柜使用未绑定单一客户的平台订舱', bookingCreateResponse, + platformScoped, 'scope=PLATFORM', OPTIONS) + if (!platformScoped) return + } const bookingId = id(bookingDraftResponse?.oceanBooking?.id) const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version) context.assert(MODULE, '三期 DRAFT 订舱返回稳定身份和初始版本', bookingCreateResponse, @@ -646,6 +659,12 @@ export async function run(context) { && id(linked?.oceanBookingId) === bookingId, `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) if (id(linked?.id) !== containerBookingId) return + if (context.context?.phase3LoadingBookingScope === 'PLATFORM') { + const platformScoped = linked?.customerId == null && linked?.customerName == null + context.assert(MODULE, '平台订舱关联后原柜保持跨客户装柜范围', linkResponse, + platformScoped, 'scope=PLATFORM', OPTIONS) + if (!platformScoped) return + } context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse, bookingConfirmed?.oceanBooking?.status === 'RELEASED' diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 513e079e5..14c32d701 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test' -import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs' +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, verifyPartialOrderCompletion, + completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -114,3 +115,191 @@ test('manual exception retries only a transport failure with the identical busin assert.match(source, /人工异常连接失败后使用同载荷重试/) assert.match(source, /manualExceptionBody/) }) + +test('quantity snapshots ignore timestamps but preserve every inventory bucket and source identity', () => { + const original = { id: '1', inboundCode: 'IN-1', totalPackages: 5, availablePackages: 3, + holdPackages: 2, allocatedPackages: 0, pickedPackages: 0, updatedAt: 'earlier' } + assert.deepEqual(inventoryQuantitySnapshot([original]), inventoryQuantitySnapshot([{ ...original, updatedAt: 'later' }])) + for (const field of ['id', 'inboundCode', 'totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages']) { + assert.notDeepEqual(inventoryQuantitySnapshot([original]), + inventoryQuantitySnapshot([{ ...original, [field]: field === 'id' || field === 'inboundCode' ? 'different' : 99 }])) + } +}) + +function contextFor(request) { + const assertions = [] + const calls = [] + return { marker: 'TAIL-CURRENT', options: { gateway: 'https://test.invalid' }, createdResources: {}, + assertions, calls, + async request(method, path, body, token) { calls.push({ method, path, body, token }); return request(method, path, body, token) }, + apiSuccess(module, name, response) { assertions.push({ name, passed: response?.body?.code === '200' }); return response.body?.data }, + assert(module, name, response, passed) { assertions.push({ name, passed }) }, + addStep(module, name, response, passed) { assertions.push({ name, passed }) }, + addCoverageGap(module, scenario, name) { assertions.push({ name, passed: false }) }, + } +} +const ok = data => ({ http: 200, body: { code: '200', data } }) + +test('partial completion rejects different Orders and an already delivered pending fragment', async () => { + for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' }, + { orderId: 'ORDER', processStatus: 'DELIVERED' }]) { + const context = contextFor((method, path) => ok({ records: path.includes('inboundCode=IN-A') + ? [{ inboundCode: 'IN-A', orderId: 'ORDER', processStatus: 'DELIVERED' }] + : [{ inboundCode: 'IN-B', ...second }] })) + assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100', + deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), false) + assert.equal(context.calls.some(call => call.path.startsWith('/api/platform/v1/orders/')), false) + } +}) + +test('partial completion verifies an unfinished valid sibling and catches premature COMPLETED', async () => { + for (const mainStatus of ['IN_TRANSIT', 'COMPLETED']) { + const context = contextFor((method, path) => path.startsWith('/api/platform/v1/orders/') + ? ok({ summary: { mainStatus } }) : ok({ records: [{ inboundCode: path.includes('inboundCode=IN-A') ? 'IN-A' : 'IN-B', + orderId: 'ORDER', processStatus: path.includes('inboundCode=IN-A') ? 'DELIVERED' : 'WAIT_SHIP' }] })) + assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100', + deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), mainStatus !== 'COMPLETED') + } +}) + +test('same dispatch-return exception reaches actual restored inventory and retries without another ledger row', async () => { + for (const initialStatus of ['PENDING', 'PROCESSING']) { + let completed = false + let version = initialStatus === 'PROCESSING' ? 1 : 0 + let uploads = 0 + const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 } + const context = contextFor((method, path, body) => { + if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1', + customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', + palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1 }]) + if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100', + overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8, + availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] }) + if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2', + customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1', + outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] }) + if (path.includes('/locations')) return ok({ total: 1, records: [{ id: '72', locationCode: 'L-1' }] }) + if (method === 'POST' && path.endsWith('/pallets')) return ok('71') + if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) + if (path.includes('/start-processing')) { version = 1; return ok({ status: 'PROCESSING', version }) } + if (path.includes('/complete')) { + assert.deepEqual(body.receipts[0], { outboundPalletItemId: '51', inventoryPositionId: '61', receivedPackages: 1, + targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', targetLocationId: '72', targetLocationCode: 'L-1' }) + const replayed = completed; completed = true + return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed }) + } + if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } }) + return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN', + outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] }) + }) + context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + const resume = initialStatus === 'PROCESSING' + ? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null + const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }) + assert.equal(result?.restoredPackages, 1) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.filter(call => call.path.includes('/complete')).length, 2) + if (initialStatus === 'PROCESSING') { + assert.equal(context.calls.some(call => call.method === 'POST' && call.path.endsWith('/pallets')), false) + assert.equal(context.calls.some(call => call.path.includes('/start-processing')), false) + assert.equal(uploads, 0) + assert.ok(context.calls.filter(call => call.path.includes('/complete')).every(call => call.body.idempotencyKey === 'ORIGINAL-COMPLETE')) + } + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') + } +}) + +test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => { + const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 }, + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })) + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + assert.equal(await completeReturnedException(context, + { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null) + assert.equal(context.calls.length, 1) + assert.ok(context.assertions.some(row => !row.passed)) +}) + +test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => { + const context = contextFor((method, path) => { + if (path.includes('/inventory/')) return ok({ records: [], total: 0 }) + if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 }) + if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) + if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } } + return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 }, + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }) + }) + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } + assert.equal(await completeReturnedException(context, + { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 2) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2) + assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed)) + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.completeIdempotencyKey, 'ORIGINAL-COMPLETE') + assert.equal(context.calls.some(call => call.method === 'POST' && !call.path.includes('/complete')), false) +}) + +test('customer receipt acceptance downloads exact bytes and rejects a foreign customer without a platform fallback', async () => { + const context = contextFor((method, path, body, token) => path.endsWith('/iam/tenant/profile') + ? ok({ id: token === 'FOREIGN' ? '200' : '100' }) : token === 'FOREIGN' + ? { http: 404, body: { code: 'A0404' } } + : ok({ deliveryNo: 'DEL-1', files: [{ fileId: '91' }] })) + const previousFetch = globalThis.fetch + globalThis.fetch = async (url, options) => options.headers.Authorization === 'Bearer FOREIGN' + ? new Response(JSON.stringify({ code: 'A0404' }), { status: 404, headers: { 'content-type': 'application/json' } }) + : new Response('CURRENT-RECEIPT', { status: 200, headers: { 'content-type': 'application/octet-stream' } }) + try { + assert.equal(await verifyCustomerSignedDocuments(context, 'OWN', + [{ deliveryNo: 'DEL-1', fileId: '91', content: 'CURRENT-RECEIPT' }], 'FOREIGN'), true) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.some(call => call.path.includes('/api/platform/')), false) + } finally { globalThis.fetch = previousFetch } +}) + +test('SIGNED-B fixture keeps primary options and credentials unchanged and records no password or token', async () => { + const context = contextFor((method, path, body, token) => { + assert.equal(path, '/api/auth/login') + assert.equal(token, '') + assert.deepEqual(body, { loginId: 'SECONDARY', password: 'SECONDARY-IN-MEMORY' }) + return ok({ accessToken: 'SECONDARY-TOKEN' }) + }) + context.options.customerName = 'PRIMARY' + context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker } + const primary = context.context.merchantQualificationFixture + const options = context.options + const create = async (secondary, flags) => { + assert.equal(flags.publishEnvironment, false) + assert.equal(secondary.marker, `${context.marker}-SIGNED-B`) + assert.notEqual(secondary.options, context.options) + assert.notEqual(secondary.context, context.context) + assert.notEqual(secondary.createdResources, context.createdResources) + assert.equal(secondary.options.customerName, '') + secondary.context.merchantQualificationFixture = { tenantId: '200', tenantName: 'SECONDARY', username: 'SECONDARY', + password: 'SECONDARY-IN-MEMORY', marker: secondary.marker } + // Even unexpected producer fields cannot enter the report's explicit metadata whitelist. + secondary.createdResources.merchantQualificationFixture = { ...secondary.context.merchantQualificationFixture, token: 'UNEXPECTED' } + } + assert.equal(await createSignedDocumentIsolationCustomer(context, { create }), 'SECONDARY-TOKEN') + assert.equal(context.options, options) + assert.equal(context.options.customerName, 'PRIMARY') + assert.equal(context.context.merchantQualificationFixture, primary) + assert.equal(context.context.phase5TailVerification.foreignMerchantToken, 'SECONDARY-TOKEN') + const report = JSON.stringify(context.createdResources) + assert.doesNotMatch(report, /password|token|IN-MEMORY|UNEXPECTED|SECONDARY-TOKEN/) + assert.equal(context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer.marker, 'TAIL-CURRENT-SIGNED-B') + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +test('incomplete SIGNED-B producer remains a blocker and never logs in as the primary customer', async () => { + const context = contextFor(() => { throw new Error('must not log in') }) + context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker } + const token = await createSignedDocumentIsolationCustomer(context, { create: async () => {} }) + assert.equal(token, null) + assert.equal(context.calls.length, 0) + assert.ok(context.assertions.some(row => !row.passed)) + assert.equal(context.context.phase5TailVerification, undefined) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 7005eaab7..0f31e2a9f 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -1,4 +1,5 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' +import { run as createMerchant } from './merchant-qualification-fixture.mjs' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -66,6 +67,221 @@ async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { return { result, attempts } } +async function allPages(context, path, values, name) { + const rows = [] + for (let page = 1; page <= 50; page += 1) { + const current = await platform(context, 'GET', `${name}第${page}页`, query(path, { ...values, page, size: 200 })) + if (!success(current.response) || !Array.isArray(current.data?.records)) return null + rows.push(...current.data.records) + const total = Number(current.data.total) + if (Number.isInteger(total) && rows.length >= total) { + const complete = rows.length === total && rows.every(row => nonBlank(id(row.id))) + && new Set(rows.map(row => id(row.id))).size === rows.length + context.assert(MODULE, `${name}无分页遗漏或重复`, current.response, + complete, + `rows=${rows.length}, total=${total}`, OPTIONS) + return complete ? rows : null + } + if (current.data.records.length === 0) break + } + blocker(context, name, '分页总数缺失或未读完,不将截断记录当作完整库存证据') + return null +} + +export function inventoryQuantitySnapshot(rows) { + return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode, + totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages), + holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages), + pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id)) +} + +async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { + const rows = [] + for (const inboundCode of [...new Set(inboundCodes)]) { + const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, + { customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`) + if (!current) return null + // The server's text filter may match child codes; inventory evidence must match the exact ticket. + rows.push(...current.filter(row => row.inboundCode === inboundCode + && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId)) + } + return inventoryQuantitySnapshot(rows) +} + +async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { + const rows = await allPages(context, `${LASTLEG_BASE}/inventory/ledger`, + { customerId, outboundOrderNo: outboundNo, businessType, businessOrderNo }, name) + if (!rows) return null + const exact = rows.filter(row => id(row.customerId) === customerId + && row.outboundOrderNo === outboundNo && row.businessType === businessType + && (!businessOrderNo || row.businessOrderNo === businessOrderNo)) + context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length, + `rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS) + return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode, + palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages), + beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) +const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) + +// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. +export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { + if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) + || !Array.isArray(pendingInboundCodes) || pendingInboundCodes.length === 0 + || pendingInboundCodes.includes(deliveredInboundCode)) { + blocker(context, '同Order多有效入仓分片', '缺少同一订单一片已签收及另一有效片未签收的本轮资源') + return false + } + const rows = [] + for (const inboundCode of [deliveredInboundCode, ...pendingInboundCodes]) { + const current = await platform(context, 'GET', `核对同Order分片${inboundCode}`, + query('/api/platform/inbounds', { tenantId: customerId, inboundCode, page: 1, size: 200 })) + rows.push(current.data?.records?.find(row => row.inboundCode === inboundCode)) + } + const sameOrder = rows.every(row => row && id(row.orderId) === id(orderId)) + const validPartialCompletion = sameOrder && rows[0]?.processStatus === 'DELIVERED' + && rows.slice(1).every(row => nonBlank(row.processStatus) && !['DELIVERED', 'CANCELLED'].includes(row.processStatus)) + context.assert(MODULE, '多分片完成门禁使用同一Order且各入仓码有效', null, + validPartialCompletion, + `codes=${[deliveredInboundCode, ...pendingInboundCodes].join(',')}`, OPTIONS) + if (!validPartialCompletion) return false + const current = await platform(context, 'GET', '未全签收时回查订单完成门禁', + query(`/api/platform/v1/orders/${encodeURIComponent(id(orderId))}`, { tenantId: customerId })) + const blocked = nonBlank(current.data?.summary?.mainStatus) && current.data.summary.mainStatus !== 'COMPLETED' + context.assert(MODULE, '同一Order仍有有效未签收分片不得提前COMPLETED', current.response, + blocked, `mainStatus=${current.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + return sameOrder && blocked +} + +export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) { + const owned = context.createdResources?.phase5OverseasFulfillment + if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) { + blocker(context, '同派送退回异常实收归属', '必须复用本轮createdResources内的原派送退回异常和原出库号') + return null + } + const exceptionPath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}`, { customerId }) + const before = await platform(context, 'GET', '读取本轮派送退回异常冻结来源', exceptionPath) + const order = before.data?.order + const sources = before.data?.returnSources + if (!success(before.response) || !['PENDING', 'PROCESSING'].includes(order?.status) || order?.exceptionType !== 'RETURN' + || order?.outboundNo !== outboundNo || !Array.isArray(sources) || sources.length !== 1) { + blocker(context, '同派送退回异常实收', '需要本轮PENDING/PROCESSING退回异常及唯一原出库来源,不能借普通退货单替代', before.response) + return null + } + const continuing = order.status === 'PROCESSING' + const previousAttempt = resume || owned.returnReceiptAttempt + if (continuing && (!/^\d+$/.test(id(previousAttempt?.targetPalletId)) + || !nonBlank(previousAttempt?.targetPalletNo) || !/^\d+$/.test(id(previousAttempt?.evidenceFileId)) + || (previousAttempt.exceptionNo && previousAttempt.exceptionNo !== exceptionNo) + || (previousAttempt.outboundNo && previousAttempt.outboundNo !== outboundNo))) { + blocker(context, '同派送退回实收续验资料', 'PROCESSING续验必须保留原目标托盘与已上传证据文件ID,不重复开始或重新建资源', before.response) + return null + } + const source = sources[0] + const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存') + const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo) + if (!stockBefore || !ledgerBefore) return null + const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}` + const palletCode = continuing ? previousAttempt.targetPalletNo + : `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) + let targetPalletId = continuing ? id(previousAttempt.targetPalletId) : '' + if (!continuing) { + const created = await platform(context, 'POST', '创建本轮派送退回实收目标托盘', `${resourceBase}/pallets`, + { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: `${context.marker}-delivery-return` }) + targetPalletId = id(created.data) + if (!success(created.response) || !/^\d+$/.test(targetPalletId)) return null + } + const pallet = await platform(context, 'GET', '回查退回目标托盘实际库位', `${resourceBase}/pallets/${targetPalletId}`) + const locations = await allPages(context, `${resourceBase}/locations`, {}, '读取退回目标托盘库位') + const location = locations?.find(row => id(row.id) === id(pallet.data?.locationId)) + context.assert(MODULE, '退回实收目标托盘和库位属于同一海外仓', pallet.response, + id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode + && Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS) + if (!location?.locationCode) return null + let processingVersion = Number(order.version) + let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' + if (!continuing) { + const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } + const started = await platform(context, 'POST', '开始处理同一派送退回异常', + query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody) + if (!success(started.response) || started.data?.status !== 'PROCESSING') return null + processingVersion = Number(started.data.version) + const evidence = await context.requestMultipart('POST', + query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }), + { name: `${context.marker}-return-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} synthetic return receipt evidence ${exceptionNo}`) }) + const uploaded = context.apiSuccess(MODULE, '上传同派送退回异常实收证据', evidence, trace(evidence), OPTIONS) + if (!success(evidence) || !/^\d+$/.test(id(uploaded?.fileId))) return null + evidenceFileId = id(uploaded.fileId) + } + const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey) + ? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE') + if (!Number.isInteger(processingVersion) || processingVersion < 0) { + blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response) + return null + } + owned.returnReceiptAttempt = { exceptionNo, outboundNo, targetPalletId, targetPalletNo: palletCode, + evidenceFileId, completeIdempotencyKey } + const completeBody = { expectedVersion: processingVersion, idempotencyKey: completeIdempotencyKey, + handlingResult: `${context.marker}-实际退回一箱并恢复来源库存`, evidenceFileIds: [evidenceFileId], + receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: id(source.inventoryPositionId), + receivedPackages: 1, targetPalletId, targetPalletNo: palletCode, + targetLocationId: id(location.id), targetLocationCode: location.locationCode }] } + const completePath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/complete`, { customerId }) + const completed = await platform(context, 'POST', '同派送退回异常完成实收及恢复库存', completePath, completeBody) + context.assert(MODULE, '同派送退回异常完成实收恰好恢复一箱', completed.response, + completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1 + && /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS) + if (!success(completed.response)) { + const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存') + const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo) + context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response, + sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS) + return null + } + const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存') + const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo) + const targetPositions = [] + for (const balance of stockAfter || []) { + const positions = await platform(context, 'GET', '回查退回目标托盘实际库存位置', + query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balance.id)}/positions`, { customerId, palletNo: palletCode })) + if (Array.isArray(positions.data)) targetPositions.push(...positions.data.filter(row => row.palletNo === palletCode)) + } + context.assert(MODULE, '退回一箱实际落到新目标托盘和原票库存位置', null, + targetPositions.length === 1 && targetPositions[0].inboundCode === order.inboundCode + && id(targetPositions[0].palletId) === targetPalletId && id(targetPositions[0].warehouseLocationId) === id(location.id) + && id(targetPositions[0].customerId) === customerId && id(targetPositions[0].overseasWarehouseId) === warehouseId + && Number(targetPositions[0].availablePackages) === 1 && Number(targetPositions[0].totalPackages) === 1, + `targetPositionCount=${targetPositions.length}, targetPallet=${palletCode}`, OPTIONS) + const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id)) + context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, + stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 + && newRows?.length === 1 && newRows[0].deltaPackages === 1 + && newRows[0].afterPackages - newRows[0].beforePackages === 1, + `before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS) + const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody) + const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存') + const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo) + context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response, + replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId) + && sameSnapshot(stockAfter, stockReplay) && sameSnapshot(ledgerAfter, ledgerReplay), trace(replay.response), OPTIONS) + const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath) + const delivery = await platform(context, 'GET', '回查退回实收后的原派送单', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId })) + context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response, + final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo + && final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId) + && final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId) + && id(row.inventoryPositionId) === id(source.inventoryPositionId)) + && delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo, + trace(final.response), OPTIONS) + return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId, + inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages), + targetPositionIds: targetPositions.map(row => id(row.id)) } +} + export async function saveReceiptAndComplete(context, delivery, customerId, index) { const deliveryNo = id(delivery.deliveryNo) const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) @@ -86,6 +302,11 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse, /^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS) if (!success(uploadResponse) || !fileId) return null + if (context.createdResources.phase5OverseasFulfillment) { + const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= [] + receipts.push({ deliveryNo, fileId, + content: `${context.marker} receipt acceptance for ${deliveryNo}` }) + } const existing = (before.data?.categories || []).map((category) => ({ category: category.category, @@ -115,6 +336,112 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null } +async function receiptBinary(context, token, path) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + headers: { Authorization: `Bearer ${token}`, Accept: '*/*' }, signal: AbortSignal.timeout(120_000), + }) + const bytes = new Uint8Array(await response.arrayBuffer()) + const contentType = response.headers.get('content-type') || '' + let body = null + if (/json/i.test(contentType)) { + try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* keep non-JSON failure evidence */ } + } + return { http: response.status, body, bytes, contentType, + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } + } catch (error) { + return { http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error) } + } +} + +const hiddenOrForbidden = response => [403, 404].includes(response?.http) + || (response?.http === 200 && ['403', '404', 'A0403', 'A0404'].includes(code(response))) + +export async function createSignedDocumentIsolationCustomer(context, { create = createMerchant } = {}) { + // Own options, credentials and resource storage prevent the secondary fixture from changing the main customer. + const secondary = Object.create(context) + secondary.marker = `${context.marker}-SIGNED-B` + secondary.options = { ...context.options, customerName: '' } + secondary.context = { ...context.context } + secondary.createdResources = {} + await create(secondary, { publishEnvironment: false }) + const fixture = secondary.context?.merchantQualificationFixture + const safe = secondary.createdResources?.merchantQualificationFixture + const metadata = { tenantId: id(safe?.tenantId), tenantName: safe?.tenantName, + username: safe?.username, marker: safe?.marker } + const ready = fixture?.marker === secondary.marker && metadata.marker === secondary.marker + && /^\d+$/.test(metadata.tenantId) && metadata.tenantId === id(fixture?.tenantId) + && nonBlank(fixture?.username) && nonBlank(fixture?.password) + context.assert(MODULE, '签收资料跨企业验收具有独立SIGNED-B本轮客户', null, + ready, `fixtureMarker=${metadata.marker || 'missing'}, customerIdPresent=${Boolean(metadata.tenantId)}`, OPTIONS) + if (!ready) { + blocker(context, 'SIGNED-B客户会话', '独立隔离客户前置创建不完整,不使用主客户替代') + return null + } + context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer = metadata + const login = await context.request('POST', '/api/auth/login', + { loginId: fixture.username, password: fixture.password }, '') + const auth = context.apiSuccess(MODULE, '独立SIGNED-B客户登录用于签收资料隔离', login, trace(login), OPTIONS) + if (!success(login) || !nonBlank(auth?.accessToken)) { + blocker(context, 'SIGNED-B客户会话', '独立客户登录未返回有效会话', login) + return null + } + context.context.phase5TailVerification = { ...context.context.phase5TailVerification, + foreignMerchantToken: auth.accessToken } + return auth.accessToken +} + +export async function verifyCustomerSignedDocuments(context, merchantToken, receiptFiles, foreignToken = null) { + if (!nonBlank(merchantToken) || !Array.isArray(receiptFiles) || receiptFiles.length === 0) { + blocker(context, '客户完成后签收资料', '缺少本轮真实已绑定签收文件与客户会话') + return false + } + let verified = true + if (foreignToken) { + const own = await merchant(context, merchantToken, 'GET', '签收文件隔离核对本客户会话身份', '/api/merchant/v1/iam/tenant/profile') + const foreign = await merchant(context, foreignToken, 'GET', '签收文件隔离核对另一客户会话身份', '/api/merchant/v1/iam/tenant/profile') + const distinct = /^\d+$/.test(id(own.data?.id)) && /^\d+$/.test(id(foreign.data?.id)) && id(own.data.id) !== id(foreign.data.id) + context.assert(MODULE, '签收文件隔离必须使用两个不同企业的真实客户会话', foreign.response, + distinct, `distinctCustomerIdentity=${distinct}`, OPTIONS) + if (!distinct) return false + } + for (const receipt of receiptFiles) { + const base = `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(receipt.deliveryNo)}/documents/receipt` + const detail = await merchant(context, merchantToken, 'GET', `客户读取已完成派送${receipt.deliveryNo}签收资料`, base) + const bound = detail.data?.deliveryNo === receipt.deliveryNo + && detail.data?.files?.some(file => id(file.fileId) === id(receipt.fileId)) + context.assert(MODULE, '客户签收只读资料返回本单已绑定文件', detail.response, bound, trace(detail.response), OPTIONS) + const downloadPath = `${base}/files/${encodeURIComponent(id(receipt.fileId))}/download` + const download = await receiptBinary(context, merchantToken, downloadPath) + const exactBytes = download.http === 200 && /application\/octet-stream/i.test(download.contentType) + && new TextDecoder().decode(download.bytes) === receipt.content + context.addStep(MODULE, '客户下载自身签收文件字节等于本轮上传内容', download, exactBytes, trace(download), OPTIONS) + verified &&= Boolean(bound && exactBytes) + const otherReceipt = receiptFiles.find(other => other.deliveryNo !== receipt.deliveryNo) + if (otherReceipt) { + const wrongFile = await receiptBinary(context, merchantToken, + `${base}/files/${encodeURIComponent(id(otherReceipt.fileId))}/download`) + context.addStep(MODULE, '客户不得将另一派送单签收文件挂到本单读取', wrongFile, + hiddenOrForbidden(wrongFile), trace(wrongFile), OPTIONS) + verified &&= hiddenOrForbidden(wrongFile) + } + if (foreignToken) { + const deniedDetail = await context.request('GET', base, undefined, foreignToken, {}) + context.addStep(MODULE, '另一客户不得读取本企业派送签收资料', deniedDetail, + hiddenOrForbidden(deniedDetail), trace(deniedDetail), OPTIONS) + const deniedFile = await receiptBinary(context, foreignToken, downloadPath) + context.addStep(MODULE, '另一客户不得下载本企业派送签收文件', deniedFile, + hiddenOrForbidden(deniedFile), trace(deniedFile), OPTIONS) + verified &&= hiddenOrForbidden(deniedDetail) && hiddenOrForbidden(deniedFile) + } + } + if (!foreignToken) { + blocker(context, '客户签收文件跨企业隔离', '本轮缺少独立另一客户会话,自己的下载成功不能替代跨客户拒绝') + return false + } + return verified +} + export function relabelOutboundMarksMatch(detail, relabel) { const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') .split(',').map(mark => mark.trim()).filter(Boolean)) @@ -349,16 +676,40 @@ export async function run(context) { const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null + const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] + if (dispatchInboundCodes.length === 0) { + blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') + await disablePicker(context, picker, 5) + return + } + const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存') + // Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks. + const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水') try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } - if (!await pickAll(context, picker.token, dispatchNo)) return + if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return + const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存') + const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水') + const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id)) + const expectedPackages = Number(transferred.data.packageCount) + context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, + stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages + && expectedPackages > 0 && shippedRows?.length > 0 + && -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages + && shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages), + `before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS) + const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response, + ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS) + if (!success(ready.response) || ready.data?.canDepart !== true) return const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response, + context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, trace(departed.response), OPTIONS) if (!success(departed.response)) return @@ -368,6 +719,16 @@ export async function run(context) { departReplay.data?.replayed === true && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) + const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存') + const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水') + context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, + sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) + context.createdResources.phase5OverseasFulfillment.inventoryProof = { + inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), + afterPackages: quantityTotal(stockAfterDepart), expectedPackages, + shipmentLedgerIds: (shippedRows || []).map(row => row.id), + canDepart: ready.data?.canDepart === true, + } } finally { await disablePicker(context, picker, 5) if (picker) picker.token = null @@ -453,6 +814,17 @@ export async function run(context) { context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) + context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId + const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态', + `/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`) + context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response, + customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED' + && id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId, + trace(customerCompletedOrder.response), OPTIONS) + const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken + || await createSignedDocumentIsolationCustomer(context) + await verifyCustomerSignedDocuments(context, merchantToken, + context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken) const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { @@ -531,5 +903,9 @@ export async function run(context) { exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo) && exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo), `records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS) - context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT' + const returnReceipt = await completeReturnedException(context, { customerId, warehouseId, + exceptionNo: returnExceptionNo, outboundNo }) + context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt + context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt + ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/event-delivery-fault-proxy.py b/scripts/event-delivery-fault-proxy.py new file mode 100644 index 000000000..0dfbd0a6d --- /dev/null +++ b/scripts/event-delivery-fault-proxy.py @@ -0,0 +1,296 @@ +"""Explicitly selected test-event ACK loss. Does not create or replay events. + +Run with an explicit consumer URL, event type and exactly one event selector. +EVENT_FAULT_CONTROL_TOKEN protects the separate loopback control listener. +Default PASS; authenticated POST /mode {"mode":"FAULT"} enables ACK loss. +The parent operator owns producer configuration, DEAD/retry and restoration. +""" + +import argparse +import hashlib +import hmac +import json +import os +import re +import signal +import threading +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MAX_BODY = 8 * 1024 * 1024 +EVENT_ID = re.compile(r"(?:[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}|[0-9A-HJKMNP-TV-Z]{26})\Z") +HASH = re.compile(r"sha256:[0-9a-fA-F]{64}\Z") +HOP_HEADERS = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"} + + +def encoded(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +class State: + def __init__(self, event_type, event_id, loading_plan_id, log_path=None): + self.event_type = event_type + self.selector_id = event_id + self.plan_id = loading_plan_id + self.log_path = log_path + self.lock = threading.Lock() + self.mode = "PASS" + self.event_id = None + self.payload_hash = None + self.envelope_hash = None + self.attempt = 0 + self.records = [] + + def select(self, body): + try: + event = json.loads(body) + except (ValueError, UnicodeError): + return None, False, None, 0, self.mode + if not isinstance(event, dict): + return None, False, None, 0, self.mode + event_id, payload_hash = event.get("eventId"), event.get("payloadHash") + # Only valid bounded identifiers may enter evidence, never arbitrary payload strings. + if not isinstance(event_id, str) or not EVENT_ID.fullmatch(event_id): + return None, False, None, 0, self.mode + if not isinstance(payload_hash, str) or not HASH.fullmatch(payload_hash): + with self.lock: + if self.event_id == event_id: + self.attempt += 1 + return {"eventId": event_id, "payloadHash": self.payload_hash}, True, \ + "TARGET_EVENT_HASH_CONFLICT", self.attempt, self.mode + return None, False, None, 0, self.mode + identity = {"eventId": event_id, "payloadHash": payload_hash.lower()} + payload = event.get("payload") + qualifies = event.get("eventType") == self.event_type and ( + event_id == self.selector_id if self.selector_id else + isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id) + envelope_hash = hashlib.sha256(json.dumps(event, sort_keys=True, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")).hexdigest() + with self.lock: + # Reusing the pinned ID with another hash/content/type is rejected even if a + # changed selector field would otherwise make the request look unrelated. + if self.event_id == event_id and (self.payload_hash != payload_hash.lower() + or self.envelope_hash != envelope_hash): + self.attempt += 1 + return identity, True, "TARGET_EVENT_HASH_CONFLICT", self.attempt, self.mode + if not qualifies or (self.event_id is not None and self.event_id != event_id): + return identity, False, None, 0, self.mode + if self.event_id is None: + self.event_id, self.payload_hash, self.envelope_hash = event_id, payload_hash.lower(), envelope_hash + self.attempt += 1 + return identity, True, None, self.attempt, self.mode + + def record(self, identity, matched, attempt, outcome, error_code): + record = {"time": datetime.now(timezone.utc).isoformat(), + "eventId": identity.get("eventId") if identity else None, + "payloadHash": identity.get("payloadHash") if identity else None, + "outcome": outcome, "attempt": attempt, "matched": matched, "errorCode": error_code} + with self.lock: + # Store only target evidence; unrelated delivery payloads/identities are not retained. + if matched: + self.records.append(record) + if self.log_path: + with open(self.log_path, "a", encoding="utf-8") as log: + log.write(json.dumps(record, separators=(",", ":")) + "\n") + + def snapshot(self): + with self.lock: + return {"mode": self.mode, "eventId": self.event_id, "payloadHash": self.payload_hash, + "attempt": self.attempt, "records": list(self.records)} + + +class QuietHandler(BaseHTTPRequestHandler): + def log_message(self, _format, *_args): + pass + + def reply(self, status, body, content_type="application/json", headers=None): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + for name, value in (headers or {}).items(): + if name.lower() not in HOP_HEADERS | {"content-type"}: + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def safe_error(self, status, code): + self.reply(status, encoded({"errorCode": code})) + + def read_body(self): + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError: + length = -1 + if not 0 <= length <= MAX_BODY: + self.safe_error(413, "INVALID_BODY_LENGTH") + return None + body = self.rfile.read(length) + if len(body) != length: + self.safe_error(400, "INCOMPLETE_BODY") + return None + return body + + +def data_handler(state, upstream, timeout): + parts = urllib.parse.urlsplit(upstream) + endpoint = urllib.parse.urlunsplit(("", "", parts.path or "/", parts.query, "")) + + class DataHandler(QuietHandler): + def do_POST(self): + if self.path != endpoint: + self.safe_error(404, "UNEXPECTED_EVENT_ENDPOINT") + return + body = self.read_body() + if body is None: + return + identity, matched, error_code, attempt, mode = state.select(body) + if error_code: + state.record(identity, matched, attempt, None, error_code) + self.safe_error(409, error_code) + return + # Forward the exact body and end-to-end headers. Never persist auth headers. + connection_headers = set(self.headers.get("Connection", "").lower().replace(" ", "").split(",")) + headers = {name: value for name, value in self.headers.items() + if name.lower() not in HOP_HEADERS | connection_headers} + request = urllib.request.Request(upstream, data=body, headers=headers, method="POST") + try: + # Redirects are forbidden: a redirect could expose a service identity token. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + try: + consumer = opener.open(request, timeout=timeout) + except urllib.error.HTTPError as error: + consumer = error + with consumer: + status = consumer.code + result = consumer.read(MAX_BODY + 1) + result_headers = dict(consumer.headers.items()) + except (OSError, urllib.error.URLError): + state.record(identity, matched, attempt, None, "UPSTREAM_TRANSPORT_ERROR") + self.safe_error(502, "UPSTREAM_TRANSPORT_ERROR") + return + if len(result) > MAX_BODY: + state.record(identity, matched, attempt, None, "UPSTREAM_BODY_TOO_LARGE") + self.safe_error(502, "UPSTREAM_BODY_TOO_LARGE") + return + outcome = None + if matched: + try: + wrapper = json.loads(result) + ack = wrapper.get("data") + valid = status == 200 and str(wrapper.get("code")) == "200" and isinstance(ack, dict) \ + and ack.get("eventId") == identity["eventId"] \ + and str(ack.get("payloadHash", "")).lower() == identity["payloadHash"] \ + and ack.get("outcome") in {"APPLIED", "DUPLICATE"} + if valid: + outcome = ack["outcome"] + except (ValueError, UnicodeError, AttributeError): + valid = False + if not valid: + state.record(identity, matched, attempt, None, "UPSTREAM_ACK_INVALID") + self.safe_error(502, "UPSTREAM_ACK_INVALID") + return + if mode == "FAULT": + state.record(identity, matched, attempt, outcome, "ACK_LOSS_INJECTED") + self.safe_error(503, "ACK_LOSS_INJECTED") + return + state.record(identity, matched, attempt, outcome, None) + self.reply(status, result, result_headers.get("Content-Type", "application/json"), result_headers) + + return DataHandler + + +def control_handler(state, control_token): + class ControlHandler(QuietHandler): + def authorized(self): + received = self.headers.get("X-Event-Fault-Control-Token", "") + if not hmac.compare_digest(received.encode(), control_token.encode()): + self.safe_error(403, "CONTROL_FORBIDDEN") + return False + return True + + def do_GET(self): + if not self.authorized(): + return + if self.path != "/status": + self.safe_error(404, "UNKNOWN_CONTROL_ENDPOINT") + return + self.reply(200, encoded(state.snapshot())) + + def do_POST(self): + if not self.authorized(): + return + if self.path != "/mode": + self.safe_error(404, "UNKNOWN_CONTROL_ENDPOINT") + return + body = self.read_body() + if body is None: + return + try: + value = json.loads(body) + mode = value["mode"] + if mode not in {"PASS", "FAULT"}: + raise ValueError() + except (ValueError, KeyError, TypeError, UnicodeError): + self.safe_error(400, "INVALID_MODE") + return + with state.lock: + state.mode = mode + self.reply(200, encoded(state.snapshot())) + + return ControlHandler + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--upstream", required=True) + parser.add_argument("--event-type", required=True) + selector = parser.add_mutually_exclusive_group(required=True) + selector.add_argument("--event-id") + selector.add_argument("--loading-plan-id") + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--control-port", type=int, default=0) + parser.add_argument("--timeout", type=float, default=20) + parser.add_argument("--log") + args = parser.parse_args() + parts = urllib.parse.urlsplit(args.upstream) + token = os.environ.get("EVENT_FAULT_CONTROL_TOKEN", "") + if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password or parts.fragment: + parser.error("upstream must be an explicit HTTP consumer endpoint without credentials or fragment") + if len(token) < 24: + parser.error("EVENT_FAULT_CONTROL_TOKEN must have at least 24 characters") + if args.event_id and not EVENT_ID.fullmatch(args.event_id): + parser.error("event-id must be a UUID or ULID") + if args.loading_plan_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.loading_plan_id): + parser.error("loading-plan-id must be a positive decimal identifier") + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120: + parser.error("invalid event type or timeout") + state = State(args.event_type, args.event_id, args.loading_plan_id, args.log) + data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout)) + control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token)) + for server in (data, control): + server.daemon_threads = True + stop = threading.Event() + for number in (signal.SIGINT, signal.SIGTERM): + signal.signal(number, lambda *_args: stop.set()) + threads = [threading.Thread(target=server.serve_forever, daemon=True) for server in (data, control)] + for thread in threads: + thread.start() + print(json.dumps({"ready": True, "dataPort": data.server_port, "controlPort": control.server_port}), flush=True) + stop.wait() + for server in (data, control): + server.shutdown() + server.server_close() + for thread in threads: + thread.join() + + +if __name__ == "__main__": + main() diff --git a/scripts/event-delivery-fault-proxy.test.mjs b/scripts/event-delivery-fault-proxy.test.mjs new file mode 100644 index 000000000..ad2828bb3 --- /dev/null +++ b/scripts/event-delivery-fault-proxy.test.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const script = fileURLToPath(new URL('./event-delivery-fault-proxy.py', import.meta.url)) +const targetId = 'e6259b1a-e112-4233-83e1-ae0071db3c52' +const otherId = '1b344fb7-d5d9-4324-b2d1-51bd0ab3af55' +const samePlanOtherId = '05dab8d1-10fd-4c9e-b242-54f4c6ff76a4' +const hash = `sha256:${'a'.repeat(64)}` +const secret = 'test-service-identity-secret-do-not-log' +const controlToken = 'test-control-secret-with-more-than-24-characters' +const event = (id = targetId, planId = '2100000000000000001') => ({ eventId: id, + eventType: 'LoadingCompleted', payloadHash: hash, + payload: { loadingPlanId: planId, syntheticPrivateField: secret } }) +const close = server => new Promise(resolve => server.close(resolve)) + +async function fixture(selector = ['--loading-plan-id', '2100000000000000001']) { + const calls = [] + const applied = new Set() + let invalidAck = false + const consumer = createServer(async (request, response) => { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const raw = Buffer.concat(chunks).toString() + const envelope = JSON.parse(raw) + calls.push({ raw, authorization: request.headers.authorization, eventId: envelope.eventId }) + const outcome = applied.has(envelope.eventId) ? 'DUPLICATE' : 'APPLIED' + applied.add(envelope.eventId) + const body = JSON.stringify({ code: '200', data: { eventId: invalidAck ? otherId : envelope.eventId, + payloadHash: envelope.payloadHash, outcome } }) + response.writeHead(200, { 'Content-Type': 'application/json', 'X-Consumer-Evidence': 'actual-upstream' }) + response.end(body) + }) + await new Promise(resolve => consumer.listen(0, '127.0.0.1', resolve)) + const directory = await mkdtemp(join(tmpdir(), 'event-fault-offline-')) + const log = join(directory, 'evidence.jsonl') + const child = spawn(process.env.PYTHON || 'python', [script, '--upstream', + `http://127.0.0.1:${consumer.address().port}/internal/events`, '--event-type', 'LoadingCompleted', + ...selector, '--log', log], { env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken }, + stdio: ['ignore', 'pipe', 'pipe'] }) + let output = ''; let errors = '' + child.stdout.on('data', chunk => { output += chunk }) + child.stderr.on('data', chunk => { errors += chunk }) + const ready = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill(); reject(new Error('Proxy readiness timeout')) }, 8000) + const onData = () => { + const line = output.split('\n').find(item => item.trim()) + if (!line) return + try { const value = JSON.parse(line); clearTimeout(timer); child.stdout.off('data', onData); resolve(value) } + catch { clearTimeout(timer); reject(new Error('Invalid proxy readiness JSON')) } + } + child.stdout.on('data', onData) + child.once('error', error => { clearTimeout(timer); reject(error) }) + child.once('exit', code => { if (!output) { clearTimeout(timer); reject(new Error(`Proxy exit ${code}`)) } }) + }) + const dataUrl = `http://127.0.0.1:${ready.dataPort}/internal/events` + const controlUrl = `http://127.0.0.1:${ready.controlPort}` + return { calls, applied, log, invalidAck: value => { invalidAck = value }, + send: async envelope => { + const sent = JSON.stringify(envelope) + const response = await fetch(dataUrl, { method: 'POST', body: sent, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` } }) + return { status: response.status, raw: await response.text(), headers: response.headers, sent } + }, + control: (path, body, token = controlToken) => fetch(`${controlUrl}${path}`, { + method: body ? 'POST' : 'GET', headers: { 'X-Event-Fault-Control-Token': token, + 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, + }), + output: () => `${output}\n${errors}`, + stop: async () => { + if (child.exitCode === null) { + const exited = new Promise(resolve => child.once('exit', resolve)) + child.kill(); await exited + } + await close(consumer) + await rm(directory, { recursive: true }) + }, + } +} + +test('ACK-loss proxy only targets one event and forwards committed upstream results before dropping ACK', async t => { + const proxy = await fixture() + t.after(() => proxy.stop()) + const unauthorized = await proxy.control('/mode', { mode: 'FAULT' }, 'wrong-control-token') + assert.equal(unauthorized.status, 403) + let status = await (await proxy.control('/status')).json() + assert.equal(status.mode, 'PASS') + assert.equal((await proxy.control('/mode', { mode: 'FAULT' })).status, 200) + + await t.test('real consumer is reached and durable APPLIED then DUPLICATE both lose ACK', async () => { + const first = await proxy.send(event()) + assert.equal(first.status, 503) + assert.equal(JSON.parse(first.raw).errorCode, 'ACK_LOSS_INJECTED') + assert.ok(proxy.applied.has(targetId)) + const duplicate = await proxy.send(event()) + assert.equal(duplicate.status, 503) + assert.equal(proxy.calls.length, 2) + assert.equal(proxy.calls[0].raw, first.sent) + assert.equal(proxy.calls[0].authorization, `Bearer ${secret}`) + status = await (await proxy.control('/status')).json() + assert.deepEqual(status.records.map(item => item.outcome), ['APPLIED', 'DUPLICATE']) + assert.deepEqual(status.records.map(item => item.attempt), [1, 2]) + }) + + await t.test('other plans, other types and another same-plan ID are unaffected', async () => { + for (const envelope of [event(otherId, '2100000000000000002'), + { ...event(otherId), eventType: 'CarrierScheduleNodeUpserted' }, event(samePlanOtherId)]) { + const result = await proxy.send(envelope) + assert.equal(result.status, 200) + assert.equal(JSON.parse(result.raw).data.eventId, envelope.eventId) + assert.equal(result.headers.get('x-consumer-evidence'), 'actual-upstream') + } + status = await (await proxy.control('/status')).json() + assert.equal(status.attempt, 2) + assert.equal(status.records.length, 2) + }) + + await t.test('pinned identity rejects hash, hidden payload and malformed-hash changes before upstream', async () => { + const before = proxy.calls.length + for (const envelope of [{ ...event(), payloadHash: `sha256:${'b'.repeat(64)}` }, + { ...event(), payload: { loadingPlanId: '2100000000000000002', changed: secret } }, + { ...event(), payloadHash: secret }]) { + const result = await proxy.send(envelope) + assert.equal(result.status, 409) + assert.equal(JSON.parse(result.raw).errorCode, 'TARGET_EVENT_HASH_CONFLICT') + } + assert.equal(proxy.calls.length, before) + }) + + await t.test('PASS forwards matching duplicate ACK and original upstream response bytes and headers', async () => { + assert.equal((await proxy.control('/mode', { mode: 'PASS' })).status, 200) + const result = await proxy.send(event()) + assert.equal(result.status, 200) + assert.equal(result.raw, JSON.stringify({ code: '200', data: { eventId: targetId, payloadHash: hash, outcome: 'DUPLICATE' } })) + assert.equal(result.headers.get('x-consumer-evidence'), 'actual-upstream') + status = await (await proxy.control('/status')).json() + assert.equal(status.records.at(-1).outcome, 'DUPLICATE') + assert.equal(status.records.at(-1).errorCode, null) + }) + + await t.test('incorrect upstream ACK is never replaced with plausible success evidence', async () => { + proxy.invalidAck(true) + const result = await proxy.send(event()) + assert.equal(result.status, 502) + assert.equal(JSON.parse(result.raw).errorCode, 'UPSTREAM_ACK_INVALID') + }) + + await t.test('state, JSONL evidence and subprocess output never expose private payload or identity secrets', async () => { + const records = (await readFile(proxy.log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + for (const record of records) { + assert.deepEqual(Object.keys(record).sort(), + ['time', 'eventId', 'payloadHash', 'outcome', 'attempt', 'matched', 'errorCode'].sort()) + assert.ok(record.eventId === targetId) + } + const evidence = `${JSON.stringify(await (await proxy.control('/status')).json())}\n${await readFile(proxy.log, 'utf8')}\n${proxy.output()}` + assert.ok(!evidence.includes(secret)) + assert.ok(!evidence.includes(controlToken)) + assert.ok(!evidence.includes('syntheticPrivateField')) + assert.ok(!evidence.includes('authorization')) + }) +}) + +test('explicit event-ID selector requires exact event type and forwards unrelated IDs unchanged', async t => { + const proxy = await fixture(['--event-id', targetId]) + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const other = await proxy.send(event(otherId)) + assert.equal(other.status, 200) + const wrongType = await proxy.send({ ...event(), eventType: 'OverseasDeliveryCompleted' }) + assert.equal(wrongType.status, 200) + const target = await proxy.send(event()) + assert.equal(target.status, 503) + const status = await (await proxy.control('/status')).json() + assert.equal(status.attempt, 1) + assert.equal(status.eventId, targetId) +}) -- 2.54.0 From d320e78bbc04916cfbe97b0fe0e4b2007072015e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:00:40 +0800 Subject: [PATCH 028/176] fix(inventory): resolve returns from immutable shipment SKU facts --- .../inventory/command/dto/ShipmentSource.java | 4 ++ .../mapper/InventoryCommandMapper.java | 12 ++-- .../service/InventoryCommandService.java | 25 ++++++-- .../DispatchShipmentSourceQueryTest.java | 55 +++++++++++++--- .../command/InventoryCommandServiceTest.java | 64 +++++++++++++------ 5 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java new file mode 100644 index 000000000..4409283a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.inventory.command.dto; + +/** SKU and shipped quantity derived only from the selected immutable outbound shipment facts. */ +public record ShipmentSource(String skuCode, long shippedPackages) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index dac3cac41..d52314fd8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.command.mapper; import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.command.dto.ShipmentSource; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -47,11 +48,11 @@ public interface InventoryCommandMapper { "AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)") int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); - @Select("SELECT COALESCE(-SUM(l.delta_packages),0) FROM ll_inventory_ledger l " + + @Select("SELECT l.sku_code AS skuCode, -SUM(l.delta_packages) AS shippedPackages FROM ll_inventory_ledger l " + "JOIN ll_outbound_order o ON o.customer_id=l.customer_id AND o.outbound_no=#{outboundNo} " + "AND o.overseas_warehouse_id=l.overseas_warehouse_id AND o.status='OUTBOUNDED' " + "WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId} " + - "AND l.sku_code=#{skuCode} AND l.business_type='SHIP' AND l.delta_packages<0 " + + "AND l.business_type='SHIP' AND l.delta_packages<0 " + "AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " + "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + @@ -62,9 +63,10 @@ public interface InventoryCommandMapper { "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT(op.idempotency_key,':',op.id))) " + - "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no))") - long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, - @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no)) " + + "GROUP BY l.sku_code") + List shipmentSources(@Param("customerId") String customerId, @Param("positionId") Long positionId, + @Param("outboundNo") String outboundNo); @Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index d606f263b..8219e9a67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -294,7 +294,7 @@ public class InventoryCommandService { || source.maxReturnPackages() <= 0) { throw badRequest("A valid returned inventory source is required"); } - String sourceSku = sku(source.skuCode()); + String sourceSku = trim(source.skuCode()); ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { @@ -351,6 +351,7 @@ public class InventoryCommandService { } Map lockedSources = new LinkedHashMap<>(); + Map resolvedSources = new LinkedHashMap<>(); Map balanceBefore = new LinkedHashMap<>(); for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { Locked locked = locked(customerId, source.inventoryPositionId()); @@ -361,22 +362,36 @@ public class InventoryCommandService { if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) { throw conflict("Returned inventory source warehouse does not match"); } - long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo, - source.skuCode()); - long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode()); + var shipmentSources = commands.shipmentSources(customerId, source.inventoryPositionId(), outboundNo); + if (shipmentSources == null || shipmentSources.isEmpty()) { + throw conflict("Immutable outbound shipment source was not found"); + } + var matchingSources = source.skuCode() == null ? shipmentSources : shipmentSources.stream() + .filter(shipment -> source.skuCode().equals(shipment.skuCode())).toList(); + if (matchingSources.size() != 1) { + throw conflict(source.skuCode() == null ? "Immutable outbound shipment SKU source is ambiguous" + : "Declared return SKU does not match an immutable shipment source"); + } + var shipment = matchingSources.get(0); + String actualSku = required(shipment.skuCode(), "Immutable outbound shipment SKU is missing"); + var resolved = new ReturnedInventoryRestoreRequest.SourceItem(source.outboundPalletItemId(), + source.inventoryPositionId(), actualSku, source.maxReturnPackages()); + long shipped = shipment.shippedPackages(); + long restored = commands.restoredPackages(customerId, returnSourceIdentity(resolved), actualSku); long requested = requestedBySource.get(source.outboundPalletItemId()); if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found"); if (restored + requested > shipped) { throw conflict("Returned packages exceed the remaining shipped packages"); } lockedSources.put(source.outboundPalletItemId(), locked); + resolvedSources.put(source.outboundPalletItemId(), resolved); balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages())); } long restoredPackages = 0; List ledgerIds = new java.util.ArrayList<>(); for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { - ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId()); + ReturnedInventoryRestoreRequest.SourceItem source = resolvedSources.get(receipt.outboundPalletItemId()); Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId()); InventoryBalance balance = sourceLocked.balance(); WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java index f79401126..50e59fb95 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -1,6 +1,9 @@ package com.baiyue.lastleg.inventory.command; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.dto.ShipmentSource; +import java.util.List; +import java.util.ArrayList; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; @@ -28,18 +31,26 @@ class DispatchShipmentSourceQueryTest { try (var statement = connection.createStatement()) { statement.execute(sql); } } - private long shipped(Connection connection, String customerId, long positionId, String outboundNo, String sku) throws Exception { - String sql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", - String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()) - .replace("#{outboundNo}", "?").replace("#{customerId}", "?") - .replace("#{positionId}", "?").replace("#{skuCode}", "?"); + private List sources(Connection connection, String customerId, long positionId, String outboundNo) throws Exception { + String sql = String.join(" ", InventoryCommandMapper.class.getMethod("shipmentSources", + String.class, Long.class, String.class).getAnnotation(Select.class).value()) + .replace("#{outboundNo}", "?").replace("#{customerId}", "?").replace("#{positionId}", "?"); try (var statement = connection.prepareStatement(sql)) { statement.setString(1, outboundNo); statement.setString(2, customerId); - statement.setLong(3, positionId); statement.setString(4, sku); - try (var result = statement.executeQuery()) { result.next(); return result.getLong(1); } + statement.setLong(3, positionId); + try (var result = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (result.next()) rows.add(new ShipmentSource(result.getString("skuCode"), result.getLong("shippedPackages"))); + return rows; + } } } + private long shipped(Connection connection, String customerId, long positionId, String outboundNo, String sku) throws Exception { + return sources(connection, customerId, positionId, outboundNo).stream() + .filter(source -> sku.equals(source.skuCode())).mapToLong(ShipmentSource::shippedPackages).sum(); + } + private void scan(Connection connection) throws Exception { execute(connection, "INSERT INTO ll_inventory_operation VALUES (71,'100','SHIP','COMPLETED','DISPATCH:DS-1:31:pick-1:SHIP')"); execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP:71')"); @@ -104,16 +115,40 @@ class DispatchShipmentSourceQueryTest { } @Test - void ordinaryFbaUnspecifiedSkuMatchesExactlyAndWrongSkuNeverFallsBack() throws Exception { + void resolvesOnlyActualImmutableSkuFactsAndGroupsQuantitiesForTheExactSource() throws Exception { try (Connection connection = database()) { scan(connection); transfer(connection); - execute(connection, "UPDATE ll_inventory_ledger SET sku_code='UNSPECIFIED'"); - assertEquals(8, shipped(connection, "100", 41, "OB-1", "UNSPECIFIED")); + assertEquals(List.of(new ShipmentSource("SKU-1", 8L)), sources(connection, "100", 41, "OB-1")); + execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (22,'100',11,32,41,3,'pick-2')"); + execute(connection, "INSERT INTO ll_inventory_operation VALUES (72,'100','SHIP','COMPLETED','DISPATCH:DS-1:32:pick-2:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'100',41,'WH-1','SKU-1','SHIP',-3,'DS-1','DS-1','DISPATCH:DS-1:32:pick-2:SHIP:72')"); + assertEquals(List.of(new ShipmentSource("SKU-1", 11L)), sources(connection, "100", 41, "OB-1")); + execute(connection, "UPDATE ll_inventory_ledger SET sku_code='MIXED' WHERE id=52"); + assertEquals(2, sources(connection, "100", 41, "OB-1").size()); + assertEquals(8, shipped(connection, "100", 41, "OB-1", "SKU-1")); + assertEquals(3, shipped(connection, "100", 41, "OB-1", "MIXED")); assertEquals(0, shipped(connection, "100", 41, "OB-1", "")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "UNSPECIFIED")); assertEquals(0, shipped(connection, "100", 41, "OB-1", "WRONG-SKU")); } } + @Test + void productionMyBatisMapperMaterializesTheCanonicalShipmentSkuAndQuantity() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + var configuration = new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment( + "source-sql", new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(), + new org.apache.ibatis.datasource.unpooled.UnpooledDataSource("org.h2.Driver", + connection.getMetaData().getURL(), "", ""))); + configuration.addMapper(InventoryCommandMapper.class); + try (var session = new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession(connection)) { + assertEquals(List.of(new ShipmentSource("SKU-1", 8L)), + session.getMapper(InventoryCommandMapper.class).shipmentSources("100", 41L, "OB-1")); + } + } + } + @Test void shipmentLedgerRequiresTheActualCompletedOperationIdentityRatherThanAPrefix() throws Exception { try (Connection connection = database()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 4e0aa51c8..1e450ed8d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +import com.baiyue.lastleg.inventory.command.dto.ShipmentSource; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.InventoryBalance; @@ -20,8 +21,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.apache.ibatis.annotations.Select; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -361,7 +362,7 @@ class InventoryCommandServiceTest { InventoryPosition source = position(201L, 200L, 0); when(commands.lockPosition("100", 201L)).thenReturn(source); when(commands.lockBalance("100", 200L)).thenReturn(balance); - when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L); + when(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource("SKU-1", 8L))); when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(1L); when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); @@ -393,7 +394,7 @@ class InventoryCommandServiceTest { InventoryPosition source = position(201L, 200L, 0); when(commands.lockPosition("100", 201L)).thenReturn(source); when(commands.lockBalance("100", 200L)).thenReturn(balance); - when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L); + when(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource("SKU-1", 8L))); when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(0L); when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); @@ -413,15 +414,14 @@ class InventoryCommandServiceTest { } @ParameterizedTest - @NullAndEmptySource - @ValueSource(strings = {" "}) - void ordinaryFbaReturnUsesTheStockInSkuNormalizationAndReplaysOnlyOnce(String sourceSku) { + @MethodSource("missingSkuSources") + void missingFbaSummarySkuUsesUniqueImmutableShipmentSkuAndReplaysOnlyOnce(String sourceSku, String actualSku) { InventoryBalance balance = balance(200L, 0); balance.setOverseasWarehouseId("900"); - balance.setSkuCode("UNSPECIFIED"); + balance.setSkuCode("MIXED"); when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); when(commands.lockBalance("100", 200L)).thenReturn(balance); - when(commands.shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED")).thenReturn(8L); + when(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource(actualSku, 8L))); when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn( @@ -438,31 +438,51 @@ class InventoryCommandServiceTest { verify(operations).insert(operation.capture()); when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue()); assertTrue(service.restoreReturnedInventory("100", "7", request).replayed()); - verify(commands).shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED"); - verify(commands).restoredPackages("100", "RETURN_SOURCE:301:201", "UNSPECIFIED"); + verify(commands).shipmentSources("100", 201L, "OUT-1"); + verify(commands).restoredPackages("100", "RETURN_SOURCE:301:201", actualSku); ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); verify(ledgers, times(1)).insert(ledger.capture()); - assertEquals("UNSPECIFIED", ledger.getValue().getSkuCode()); + assertEquals(actualSku, ledger.getValue().getSkuCode()); + assertEquals(sourceSku, request.sources().get(0).skuCode()); + assertEquals("MIXED", balance.getSkuCode()); verify(positions, times(1)).insert(any(InventoryPosition.class)); verify(balances, times(1)).updateById(any(InventoryBalance.class)); } + private static java.util.stream.Stream missingSkuSources() { + return java.util.stream.Stream.of("SKU-1", "MIXED", "UNSPECIFIED").flatMap(actualSku -> + java.util.stream.Stream.of(null, "", " ").map(sourceSku -> Arguments.of(sourceSku, actualSku))); + } + @Test - void nonEmptyWrongReturnSkuIsRejectedWithoutSearchingAnotherSkuOrWritingInventory() { + void nonEmptyWrongReturnSkuIsRejectedWithoutSearchingAnotherSourceOrWritingInventory() { + rejectReturnSku("WRONG-SKU", List.of(new ShipmentSource("SKU-1", 8L))); + } + + @Test + void missingSummarySkuWithMultipleImmutableSkuGroupsIsRejectedWithoutInventoryMutation() { + rejectReturnSku(null, List.of(new ShipmentSource("SKU-1", 8L), new ShipmentSource("MIXED", 3L))); + } + + private void rejectReturnSku(String sourceSku, List shipments) { InventoryBalance balance = balance(200L, 0); balance.setOverseasWarehouseId("900"); - balance.setSkuCode("UNSPECIFIED"); + balance.setSkuCode("MIXED"); when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(shipments); ReturnedInventoryRestoreRequest base = returnRequest(1); ReturnedInventoryRestoreRequest request = new ReturnedInventoryRestoreRequest(base.exceptionNo(), base.outboundNo(), base.overseasWarehouseId(), base.inboundOrderId(), - List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "WRONG-SKU", 5)), + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, sourceSku, 5)), base.receipts(), base.idempotencyKey(), base.evidenceFileIds()); - assertThrows(ResponseStatusException.class, () -> service.restoreReturnedInventory("100", "7", request)); - verify(commands).shippedPackages("100", 201L, "OUT-1", "WRONG-SKU"); - verify(commands, never()).shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED"); + ResponseStatusException failure = assertThrows(ResponseStatusException.class, + () -> service.restoreReturnedInventory("100", "7", request)); + assertEquals(sourceSku == null ? "Immutable outbound shipment SKU source is ambiguous" + : "Declared return SKU does not match an immutable shipment source", failure.getReason()); + verify(commands).shipmentSources("100", 201L, "OUT-1"); + verify(commands, never()).restoredPackages(any(), any(), any()); verify(positions, never()).insert(any(InventoryPosition.class)); verify(positions, never()).updateById(any(InventoryPosition.class)); verify(balances, never()).updateById(any(InventoryBalance.class)); @@ -475,7 +495,7 @@ class InventoryCommandServiceTest { balance.setOverseasWarehouseId("900"); when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); when(commands.lockBalance("100", 200L)).thenReturn(balance); - when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(4L); + when(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource("SKU-1", 4L))); when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(2L); assertThrows(ResponseStatusException.class, @@ -489,14 +509,16 @@ class InventoryCommandServiceTest { @Test void returnRestoreSqlIsBoundToImmutableShipLedgerAndSourceIdentity() throws Exception { - String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", - String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()); + String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shipmentSources", + String.class, Long.class, String.class).getAnnotation(Select.class).value()); assertTrue(shippedSql.contains("position_id=#{positionId}")); assertTrue(shippedSql.contains("o.outbound_no=#{outboundNo}")); assertTrue(shippedSql.contains("p.inventory_position_id=l.position_id")); assertTrue(shippedSql.contains("p.idempotency_key,':SHIP'")); assertTrue(shippedSql.contains("l.idempotency_key=CONCAT(op.idempotency_key,':',op.id)")); assertTrue(shippedSql.contains("business_type='SHIP'")); + assertTrue(shippedSql.contains("GROUP BY l.sku_code")); + assertFalse(shippedSql.contains("#{skuCode}")); String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", String.class, String.class, String.class).getAnnotation(Select.class).value()); assertTrue(restoredSql.contains("source_event_id=#{sourceIdentity}")); -- 2.54.0 From eae630e60df1ddfd8076f2330753cd3ebb3ff70c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:03:33 +0800 Subject: [PATCH 029/176] fix(shipping): allow platform scope tracking resources --- ...latformCarrierScheduleQueryController.java | 11 +- ...erScheduleQueryControllerContractTest.java | 121 ++++++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 9a17404d7..1b84a2cab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -66,7 +66,6 @@ public class PlatformCarrierScheduleQueryController { public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); - if (query.customerId() != null) FirstlegScopeGuard.platform(user, query.customerId()); return ApiResponse.success(service.page(query)); } @@ -74,7 +73,7 @@ public class PlatformCarrierScheduleQueryController { @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(queryId)); } @@ -82,7 +81,7 @@ public class PlatformCarrierScheduleQueryController { @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> history( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.history(queryId)); } @@ -93,7 +92,7 @@ public class PlatformCarrierScheduleQueryController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { - FirstlegScopeGuard.platform(user, request.customerId()); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } @@ -105,7 +104,7 @@ public class PlatformCarrierScheduleQueryController { @PathVariable @Positive long queryId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ManualResultRequest request) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request)); } @@ -118,7 +117,7 @@ public class PlatformCarrierScheduleQueryController { @PathVariable @Positive long queryId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ManualNodeRequest request) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request)); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 64933838e..59a830387 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -1,17 +1,138 @@ package com.baiyue.firstleg.portschedule.controller; import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.*; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import jakarta.validation.Validation; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.bind.annotation.RequestMapping; import java.lang.reflect.Method; +import java.time.LocalDateTime; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; class PlatformCarrierScheduleQueryControllerContractTest { + private final CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + private final PlatformCarrierScheduleQueryController controller = new PlatformCarrierScheduleQueryController(); + private final AuthenticatedUser platform = user("PLATFORM", 0L); + private final long queryId = 88L; + private final Query query = new Query(0L, 12L, 13L, null, null, null, null, null, null); + private final CreateRequest create = new CreateRequest(0, 0L, 12L, 13L, "COSCO", "CONTAINER", "TEST1234567", null); + private final ManualResultRequest result = new ManualResultRequest(1, 99L, "人工关联船期"); + private final ManualNodeRequest node = new ManualNodeRequest(1, "DEPARTED_ORIGIN_PORT", + LocalDateTime.of(2026, 9, 30, 10, 0), null, LocalDateTime.of(2026, 9, 30, 10, 0), + "CNSHA", "上海港", "人工确认实际开航"); + private Detail detail; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(controller, "service", service); + Task task = mock(Task.class); + when(task.customerId()).thenReturn(0L); + detail = new Detail(new QueryView(task, false), List.of(), List.of(), List.of()); + // Platform bookings persist task ownership as zero. The former positive-target guard rejected it. + when(service.customerId(anyLong())).thenReturn(0L); + } + + @Test + void platformCanFilterZeroScopeTasks() { + var page = new PageResult(List.of(detail.query()), 1, 20, 1, 1); + when(service.page(query)).thenReturn(page); + assertThat(controller.page(platform, query).data()).isSameAs(page); + verify(service).page(query); + } + + @Test + void platformCanReadZeroScopeTaskDetail() { + when(service.detail(queryId)).thenReturn(detail); + assertThat(controller.detail(platform, queryId).data()).isSameAs(detail); + assertThat(detail.query().query().customerId()).isZero(); + verify(service).detail(queryId); + } + + @Test + void platformCanReadZeroScopeTaskHistory() { + var history = List.of(new History(5L, "OUTBOUND_CONFIRM", 0, 1, "自动离仓", + 7L, "SYSTEM", LocalDateTime.of(2026, 9, 30, 10, 0))); + when(service.history(queryId)).thenReturn(history); + assertThat(controller.history(platform, queryId).data()).isSameAs(history); + verify(service).history(queryId); + } + + @Test + void platformCanCreateZeroScopeTaskThroughNormalLinkValidation() { + when(service.create(7L, "operator", "create-key", create)).thenReturn(detail); + assertThat(controller.create(platform, "create-key", create).data()).isSameAs(detail); + verify(service).create(7L, "operator", "create-key", create); + } + + @Test + void platformCanAttachZeroScopeTaskManualResult() { + when(service.attachManualResult(queryId, 7L, "operator", "result-key", result)).thenReturn(detail); + assertThat(controller.manualResult(platform, queryId, "result-key", result).data()).isSameAs(detail); + verify(service).attachManualResult(queryId, 7L, "operator", "result-key", result); + } + + @Test + void platformCanAppendZeroScopeTaskManualNode() { + when(service.addManualNode(queryId, 7L, "operator", "node-key", node)).thenReturn(detail); + assertThat(controller.manualNode(platform, queryId, "node-key", node).data()).isSameAs(detail); + verify(service).addManualNode(queryId, 7L, "operator", "node-key", node); + } + + @Test + void nonPlatformPrincipalsAreRejectedBeforeAnyResourceLookupOrMutation() { + for (AuthenticatedUser denied : Arrays.asList(null, user("MERCHANT", 42L), user("PDA", 0L), + user("PLATFORM", 42L), user("PLATFORM", null))) { + List actions = List.of( + () -> controller.carriers(denied), + () -> controller.containerActualFacts(denied, List.of(13L)), + () -> controller.page(denied, query), + () -> controller.detail(denied, queryId), + () -> controller.history(denied, queryId), + () -> controller.create(denied, "create-key", create), + () -> controller.manualResult(denied, queryId, "result-key", result), + () -> controller.manualNode(denied, queryId, "node-key", node)); + for (Runnable action : actions) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(BizException.class, + failure -> assertThat(failure.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + } + } + verifyNoInteractions(service); + } + + @Test + void requestValidationAllowsPlatformZeroAndRejectsNegativeScope() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + assertThat(validator.validate(query)).isEmpty(); + assertThat(validator.validate(create)).isEmpty(); + assertThat(validator.validate(new Query(-1L, 12L, 13L, null, null, null, null, null, null))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("customerId")); + assertThat(validator.validate(new CreateRequest(0, -1L, 12L, 13L, "COSCO", "CONTAINER", "TEST1234567", null))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("customerId")); + } + } + + private static AuthenticatedUser user(String appCode, Long scope) { + return new AuthenticatedUser(7L, scope, 0L, null, "operator", appCode, "WEB", Set.of()); + } + @Test void exposesDedicatedTaskResourceAndLeastPrivilegeActions() { assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value()) -- 2.54.0 From 74f0d823daa2ecdfae64ece7df2ffa59e504d582 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:15:20 +0800 Subject: [PATCH 030/176] test(fulfillment): reconcile exact source positions and inventory balances --- ...se5-overseas-fulfillment-contract.test.mjs | 89 +++++++++++++++++-- .../phase5-overseas-fulfillment.mjs | 67 +++++++++++--- 2 files changed, 140 insertions(+), 16 deletions(-) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 14c32d701..0313f5969 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,5 @@ import test from 'node:test' -import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, verifyPartialOrderCompletion, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, verifyPartialOrderCompletion, completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -169,12 +169,17 @@ test('same dispatch-return exception reaches actual restored inventory and retri let uploads = 0 const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 } const context = contextFor((method, path, body) => { - if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1', + if (path.includes('/inventory/balances/1/positions')) return ok(path.includes('palletNo=') ? (completed ? [{ id: '3', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', - palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1 }]) + palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : []) + : [{ id: 'OLD', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', + palletNo: 'SOURCE', totalPackages: 8, availablePackages: 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }, + ...(completed ? [{ id: '3', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', + palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])]) if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100', - overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8, - availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] }) + overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8, + availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] }) if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2', customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1', outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] }) @@ -303,3 +308,77 @@ test('incomplete SIGNED-B producer remains a blocker and never logs in as the pr assert.ok(context.assertions.some(row => !row.passed)) assert.equal(context.context.phase5TailVerification, undefined) }) + +function inventoryFixture() { + const position = (positionId, inboundCode, packages) => ({ id: positionId, balanceId: '1', customerId: '100', + overseasWarehouseId: 'WH-1', inboundCode, inboundOrderId: `ORDER-${inboundCode}`, palletId: `PALLET-${positionId}`, + palletNo: `P-${positionId}`, batchNo: 'B-1', warehouseLocationId: 'LOCATION-1', warehouseLocationCode: 'L-1', + totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }) + return { balance: { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED', + totalPackages: 9, availablePackages: 9, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 4, procurementPackages: 2 }, + positions: [position('11', 'IN-A', 3), position('12', 'IN-A-1', 2), position('13', 'IN-B', 4)] } +} +function inventoryContext(fixture) { + return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions) + : ok({ records: [fixture.balance], total: 1 })) +} + +test('inventory evidence resolves missing Balance inboundCode through exact positions and retains an independent shared balance', async () => { + const fixture = inventoryFixture() + const context = inventoryContext(fixture) + const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock') + assert.equal(snapshot.length, 1) + assert.equal(snapshot[0].id, '1') + assert.equal(snapshot[0].totalPackages, 7) + assert.deepEqual(snapshot[0].inboundCodes, ['IN-A', 'IN-B']) + assert.deepEqual(snapshot[0].matchingPositionIds, ['11', '13']) + assert.equal(snapshot[0].balance.totalPackages, 9) + assert.equal(snapshot[0].balance.inTransitPackages, 4) + assert.equal(snapshot[0].balance.procurementPackages, 2) + assert.equal(snapshot[0].positions.length, 3) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.filter(call => call.path.includes('/positions')).length, 1) + const childOnly = inventoryFixture() + childOnly.positions = [childOnly.positions[1]] + childOnly.balance.totalPackages = 2; childOnly.balance.availablePackages = 2 + assert.deepEqual(await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock'), []) +}) + +test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => { + for (const mutate of [ + fixture => { fixture.balance.customerId = 'OTHER' }, + fixture => { fixture.balance.overseasWarehouseId = 'OTHER' }, + fixture => { fixture.positions[0].customerId = 'OTHER' }, + fixture => { fixture.positions[0].overseasWarehouseId = 'OTHER' }, + fixture => { fixture.positions[0].balanceId = 'OTHER' }, + fixture => { fixture.positions[0].id = fixture.positions[1].id }, + fixture => { fixture.positions[0].inboundCode = '' }, + fixture => { fixture.positions[0].receivingPackages = 1 }, + fixture => { fixture.balance.inTransitPackages = null }, + fixture => { delete fixture.positions[0].receivingPackages }, + fixture => { fixture.balance.availablePackages = 8 }, + ]) { + const fixture = inventoryFixture(); mutate(fixture) + const context = inventoryContext(fixture) + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null) + assert.ok(context.assertions.some(row => !row.passed)) + } +}) + +test('inventory evidence captures zero drift independently for every balance bucket, position identity, and unselected sibling position', async () => { + const fixture = inventoryFixture() + const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock') + for (const field of ['inTransitPackages', 'procurementPackages']) { + const changed = inventoryFixture(); changed.balance[field] += 1 + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original) + } + for (const field of ['palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'inboundOrderId']) { + const changed = inventoryFixture(); changed.positions[2][field] = 'DIFFERENT' + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original) + } + for (const field of ['holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']) { + const changed = inventoryFixture(); changed.positions[2][field] += 1; changed.balance[field] += 1 + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original) + } +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 0f31e2a9f..5fc62a177 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -88,24 +88,66 @@ async function allPages(context, path, values, name) { return null } +const POSITION_BUCKETS = ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const BALANCE_BUCKETS = [...POSITION_BUCKETS, 'inTransitPackages', 'procurementPackages'] +const INVENTORY_IDENTITIES = ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', + 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', 'palletId', 'palletNo', + 'batchNo', 'warehouseLocationId', 'warehouseLocationCode'] + export function inventoryQuantitySnapshot(rows) { - return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode, - totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages), - holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages), - pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id)) + return (rows || []).map(row => Object.fromEntries([ + ...INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, id(row[field])]), + ...BALANCE_BUCKETS.filter(field => row[field] !== undefined).map(field => [field, Number(row[field])]), + ])).sort((a, b) => a.id.localeCompare(b.id)) } -async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { - const rows = [] - for (const inboundCode of [...new Set(inboundCodes)]) { +export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { + const codes = new Set(inboundCodes) + const candidates = new Map() + for (const inboundCode of codes) { const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, { customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`) if (!current) return null - // The server's text filter may match child codes; inventory evidence must match the exact ticket. - rows.push(...current.filter(row => row.inboundCode === inboundCode - && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId)) + for (const balance of current) { + const owned = id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId + const quantities = BALANCE_BUCKETS.every(field => balance[field] !== null && balance[field] !== undefined && balance[field] !== '' && Number.isSafeInteger(Number(balance[field])) && Number(balance[field]) >= 0) + const previous = candidates.get(id(balance.id)) + const stable = !previous || JSON.stringify(inventoryQuantitySnapshot([previous])) === JSON.stringify(inventoryQuantitySnapshot([balance])) + context.assert(MODULE, `${name}余额客户仓库数量及重复读取一致`, null, owned && quantities && stable, + `balanceId=${id(balance.id)}, owned=${owned}, quantities=${quantities}, stable=${stable}`, OPTIONS) + if (!owned || !quantities || !stable) return null + candidates.set(id(balance.id), balance) + } } - return inventoryQuantitySnapshot(rows) + const snapshot = [] + for (const balance of candidates.values()) { + const current = await platform(context, 'GET', `${name}读取余额实际全部位置`, + query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId })) + const positions = current.data + const valid = success(current.response) && Array.isArray(positions) + && new Set(positions.map(row => id(row.id))).size === positions.length + && positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === id(balance.id) + && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId + && nonBlank(row.inboundCode) + && POSITION_BUCKETS.every(field => row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0)) + context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid, + `balanceId=${id(balance.id)}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) + if (!valid) return null + const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field])) + context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled, + `balanceId=${id(balance.id)}, balanceTotal=${balance.totalPackages}`, OPTIONS) + if (!reconciled) return null + // Balance DTO need not expose inboundCode. Text-filtered child tickets cannot count as this exact source. + const exact = positions.filter(row => codes.has(row.inboundCode)) + if (!exact.length) continue + snapshot.push({ id: id(balance.id), customerId, overseasWarehouseId: warehouseId, + inboundCodes: [...new Set(exact.map(row => row.inboundCode))].sort(), + ...Object.fromEntries(POSITION_BUCKETS.map(field => [field, exact.reduce((sum, row) => sum + Number(row[field]), 0)])), + balance: inventoryQuantitySnapshot([balance])[0], + positions: inventoryQuantitySnapshot(positions), + matchingPositionIds: exact.map(row => id(row.id)).sort() }) + } + return snapshot.sort((a, b) => a.id.localeCompare(b.id)) } async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { @@ -124,6 +166,7 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp } const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) +const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0) const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) // A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. @@ -258,6 +301,7 @@ export async function completeReturnedException(context, { customerId, warehouse const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id)) context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 + && balanceQuantityTotal(stockAfter) - balanceQuantityTotal(stockBefore) === 1 && newRows?.length === 1 && newRows[0].deltaPackages === 1 && newRows[0].afterPackages - newRows[0].beforePackages === 1, `before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS) @@ -697,6 +741,7 @@ export async function run(context) { const expectedPackages = Number(transferred.data.packageCount) context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages + && balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(stockAfterPick) === expectedPackages && expectedPackages > 0 && shippedRows?.length > 0 && -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages && shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages), -- 2.54.0 From 7f3c0690cd4cb830a6c882f1428cfdc49076438a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:30:27 +0800 Subject: [PATCH 031/176] test(shipping): validate platform customer scope separately from business IDs --- .../phase4-carrier-nodes-contract.test.mjs | 60 +++++++++++++++++++ .../phase4-carrier-nodes.mjs | 5 +- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 31e660c3f..5603063c8 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -251,3 +251,63 @@ test('marker-owned pre-PDA chain covers idempotency conflicts through pickup-rea const firstPageQuery = fixture.context.calls.find((item) => item.path === `${BASE}?page=1&size=20`) assert.ok(firstPageQuery) }) + +async function taskPageStructure(overrides) { + const fixture = markerOwnedContext() + const task = { ...fixture.task, ...overrides } + const checks = [] + const originalRequest = fixture.context.request + fixture.context.createdResources = {} + fixture.context.request = async (method, requestPath, ...args) => { + if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) { + fixture.context.calls.push({ method, path: requestPath }) + return ok({ records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }) + } + return originalRequest(method, requestPath, ...args) + } + fixture.context.assert = (_module, name, _response, condition) => checks.push({ name, passed: Boolean(condition) }) + fixture.context.addStep = (_module, name, _response, passed) => checks.push({ name, passed }) + await runUntilPickupReady(fixture.context) + assert.ok(fixture.context.calls.every((call) => call.method === 'GET')) + assert.ok(checks.some((check) => check.name.includes('缺少本轮phase3出仓CarrierScheduleQuery') && !check.passed)) + return checks.find((check) => check.name === '船节点分页与长ID结构稳定')?.passed +} + +test('task page permits explicit platform scope zero and positive customer scopes as serialized longs', async () => { + for (const customerId of ['0', '7', CUSTOMER_ID]) { + assert.equal(await taskPageStructure({ customerId }), true, `customerId=${customerId}`) + } +}) + +test('task page still rejects negative, missing, numeric and noncanonical customer scopes', async () => { + for (const customerId of ['-1', '-9007199254740993002', '', '00', '01', '+0', '0.0', 'customer', null, undefined, 0, 7]) { + assert.equal(await taskPageStructure({ customerId }), false, `customerId=${String(customerId)}`) + } +}) + +test('allowing platform scope zero never relaxes positive long task, booking or container identifiers', async () => { + for (const field of ['id', 'oceanBookingId', 'containerBookingId']) { + for (const invalid of ['0', '0000000000000000', '09007199254740993', '-9007199254740993001', '7', + '9007199254740993.001', null, 9007199254740993]) { + assert.equal(await taskPageStructure({ customerId: '0', [field]: invalid }), false, + `${field}=${String(invalid)}`) + } + } +}) + +test('platform scope zero completes the same detail and manual-node lifecycle assertions without changing business identifiers', async () => { + const fixture = markerOwnedContext() + fixture.task.customerId = '0' + fixture.context.createdResources.phase3CarrierScheduleQuery.customerId = '0' + + await runUntilPickupReady(fixture.context) + + assert.equal(fixture.task.customerId, '0') + assert.equal(fixture.task.id, QUERY_ID) + assert.equal(fixture.task.oceanBookingId, OCEAN_ID) + assert.equal(fixture.task.containerBookingId, CONTAINER_ID) + assert.equal(fixture.task.latestActualNode, 'PICKUP_READY') + assert.equal(fixture.context.calls.filter((call) => call.method === 'POST').length, 15) + assert.ok(fixture.assertions.every((check) => check.passed)) + assert.ok(fixture.steps.every((check) => check.passed)) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index e25a3fdee..fe9d53590 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -16,8 +16,9 @@ function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } function nullableText(value) { return value == null || typeof value === 'string' } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } -function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } +function validLongId(value) { return typeof value === 'string' && /^[1-9]\d{15,}$/.test(value) } function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' } +function validCustomerScope(value) { return typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value) } function validOperatorId(value) { return value === '0' || validNumericId(value) } function validContainerNo(value) { if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false @@ -69,7 +70,7 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e } function validTask(task) { - return validLongId(task?.id) && Boolean(task?.queryNo) && validNumericId(task?.customerId) + return validLongId(task?.id) && Boolean(task?.queryNo) && validCustomerScope(task?.customerId) && validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status) && validLongId(task?.containerBookingId) && task?.trackingType === 'CONTAINER_NO' && validContainerNo(task?.trackingNo) && nullableText(task?.latestActualNode) -- 2.54.0 From bd73c7a326dfdcc17a32dff69644e78019764b85 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:33:53 +0800 Subject: [PATCH 032/176] fix(lastleg): expose authoritative outbound type to PDA --- .../delivery/dto/DeliveryResponses.java | 3 +- .../service/DeliveryTransferService.java | 2 +- .../openapi/overseas-pda-operations-v1.yaml | 42 +++++++++++++------ .../service/DeliveryTransferServiceTest.java | 21 ++++++++++ 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 3cbd6917a..e81a09044 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -75,7 +75,8 @@ public final class DeliveryResponses { Long palletId, Long warehousePalletId, String palletNo, int packageCount, BigDecimal grossWeightKg, BigDecimal volumeCbm) {} - public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} + public record PdaTask(Long id, String taskNo, String status, String outboundNo, String outboundType, + LocalDateTime pushedAt, LocalDateTime completedAt) {} public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, String skuCode, String inboundCode, long availablePackages, 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 46a1a9ed0..3bb1f180e 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 @@ -1114,7 +1114,7 @@ public class DeliveryTransferService { && Objects.equals(item.getInboundCode(), balance.getInboundCode()); } - private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } + private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), outbound.getOutboundType(), task.getPushedAt(), task.getCompletedAt()); } private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(), outbound.getDispatchNo()); 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 bc6fdff02..988750eaa 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 @@ -45,7 +45,15 @@ paths: summary: 查询Token绑定仓的一件代发出库任务 description: 一件代发复用统一出库任务模型;不接收customerId或warehouseId,派送不进入PDA。 parameters: [{ name: status, in: query, required: false, schema: { type: string } }] - responses: { '200': { description: 出库任务列表 } } + responses: + '200': + description: 出库任务列表;每项包含服务端出库类型 outboundType + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/PdaOutboundTask' } } /api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}: get: summary: 解析出库作业扫码业务号 @@ -68,7 +76,7 @@ paths: summary: 扫码拣取一件代发箱件 parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } } - responses: { '200': { description: 更新后的统一出库任务 }, '409': { description: 库存、数量或幂等冲突 } } + responses: { '200': { description: 更新后的统一出库任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '409': { description: 库存、数量或幂等冲突 } } /api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick: post: summary: 扫描一件代发托盘并原子拣取整托剩余箱件 @@ -77,14 +85,14 @@ paths: - { name: taskNo, in: path, required: true, schema: { type: string } } - { name: palletNo, in: path, required: true, schema: { type: string } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } - responses: { '200': { description: 整托拣货后的任务 }, '404': { description: 当前任务没有该托盘 }, '409': { description: 非一件代发、库存不足或幂等冲突 } } + responses: { '200': { description: 整托拣货后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '404': { description: 当前任务没有该托盘 }, '409': { description: 非一件代发、库存不足或幂等冲突 } } /api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete: post: summary: 完成一件代发出库任务 description: 全部托盘及箱件精确拣完后完成;同一完成幂等键返回既有结果,其他键明确冲突。 parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } - responses: { '200': { description: COMPLETED任务 }, '409': { description: 未全部拣货或幂等冲突 } } + responses: { '200': { description: COMPLETED任务, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskResponse' } } } }, '409': { description: 未全部拣货或幂等冲突 } } /api/pda/v1/last-leg/stocktakes: get: summary: 查询Token绑定仓盘点任务 @@ -868,19 +876,27 @@ components: maxItems: 500 description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 items: { type: string, minLength: 1, maxLength: 255 } + PdaOutboundTaskResponse: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/PdaOutboundTask' } + PdaOutboundTask: + type: object + required: [taskNo, status, outboundNo, outboundType] + properties: + id: { type: string, description: 内部任务键,不作为用户展示编号 } + taskNo: { type: string } + status: { type: string } + outboundNo: { type: string } + outboundType: { type: string, enum: [ONE_CLICK_DROPSHIP, RETURN_OUTBOUND, RELABEL_OUTBOUND, WAREHOUSE_TRANSFER_OUTBOUND, DISPATCH_DELIVERY], description: 正式出库单类型;列表、详情及写操作结果共用此响应 } + pushedAt: { type: string, format: date-time, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } PdaOutboundTaskDetail: type: object required: [task, items] properties: - task: - type: object - required: [taskNo, status, outboundNo] - properties: - taskNo: { type: string } - status: { type: string } - outboundNo: { type: string } - pushedAt: { type: string, format: date-time, nullable: true } - completedAt: { type: string, format: date-time, nullable: true } + task: { $ref: '#/components/schemas/PdaOutboundTask' } items: type: array items: 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 30f8f4b93..0bb621769 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 @@ -66,6 +66,27 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DeliveryTransferServiceTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"ONE_CLICK_DROPSHIP", "RETURN_OUTBOUND", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND", "DISPATCH_DELIVERY"}) + void pdaListAndDetailExposeAuthoritativeOutboundType(String outboundType) { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "type-key"); + outbound.setOverseasWarehouseId("WH-1"); + outbound.setOutboundType(outboundType); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + when(pdaTasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); + when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of()); + + assertEquals(outboundType, service.pdaTasks("WH-1", null).get(0).outboundType()); + var detail = service.pdaTaskDetail("WH-1", "PDA-701"); + assertEquals(outboundType, detail.task().outboundType()); + assertEquals(outboundType, new com.fasterxml.jackson.databind.ObjectMapper() + .valueToTree(detail).path("task").path("outboundType").asText()); + } + @Mock DispatchOrderMapper dispatchOrders; @Mock DispatchItemMapper dispatchItems; @Mock OutboundOrderMapper outboundOrders; -- 2.54.0 From 13028d8a5a4876d351b481abc4da5c92dfa4807d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:49:40 +0800 Subject: [PATCH 033/176] fix(order): complete root fulfillment only after effective remainders --- .../OrderFulfillmentCompletionClient.java | 4 +- .../InternalInboundOrderController.java | 9 + .../dto/OrderFulfillmentCompleted.java | 11 + .../OrderFulfillmentSnapshotService.java | 32 +++ .../OverseasDeliveryCompletedConsumer.java | 25 +- .../main/resources/openapi/firstleg-v1.yaml | 23 ++ ...alInboundOrderReferenceControllerTest.java | 12 + .../OrderFulfillmentSnapshotServiceTest.java | 52 ++++ ...OverseasDeliveryCompletedConsumerTest.java | 44 +++- order/pom.xml | 1 + .../order/client/FirstlegInboundClient.java | 7 + .../com/baiyue/order/mapper/OrderMapper.java | 8 + .../mapper/OrderStatusHistoryMapper.java | 7 +- .../service/OrderApplicationService.java | 3 + .../OrderFulfillmentCompletionService.java | 134 +++++++++-- .../service/OrderInboundOutboxPublisher.java | 22 ++ .../src/main/resources/openapi/order-v1.yaml | 29 ++- .../OrderApplicationServiceStateTest.java | 19 ++ ...OrderFulfillmentCompletionServiceTest.java | 14 +- ...rFulfillmentCompletionTransactionTest.java | 222 ++++++++++++++++++ .../service/OrderIdempotencyServiceTest.java | 2 + .../OrderInboundOutboxPublisherTest.java | 37 ++- .../service/PlatformOrderPageServiceTest.java | 2 + .../service/TenantOrderCountServiceTest.java | 2 + .../TenantOrderStatisticsServiceTest.java | 2 + 25 files changed, 684 insertions(+), 39 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotServiceTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionTransactionTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java index 8833d0115..1e8a0e4b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java @@ -15,7 +15,7 @@ public interface OrderFulfillmentCompletionClient { ApiResponse complete(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Request request); - record Request(String tenantId, String orderId, String orderNo, String inboundCode, + record Request(String tenantId, String orderId, String orderNo, String inboundCode, String rootOrderId, String deliveryNo, Instant completedAt, String eventId, String payloadHash) { } - record Result(Long orderId, String status, boolean replayed) { } + record Result(Long orderId, String status, boolean replayed, Long rootOrderId, String rootStatus) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 24b464e85..b8f0ced52 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -47,6 +47,7 @@ public class InternalInboundOrderController { private OrderDeclarationSyncService declarationSync; @Resource private OrderRemarksSyncService remarksSync; + @Resource private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -56,6 +57,14 @@ public class InternalInboundOrderController { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + @PostMapping("/order-fulfillment-completed") + public ApiResponse completeFulfillmentSnapshot( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody com.baiyue.firstleg.dto.OrderFulfillmentCompleted command) { + requireToken(token); + return ApiResponse.success(fulfillmentSnapshot.complete(command)); + } + @PostMapping("/order-approved") public ApiResponse createFromApprovedOrder( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java new file mode 100644 index 000000000..d9cb5cfef --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDateTime; + +/** Order authoritative final state, delivered through its transactional Outbox. */ +public record OrderFulfillmentCompleted(@Positive long tenantId, @Positive long orderId, + @NotBlank String orderNo, @NotBlank String inboundCode, + @NotNull LocalDateTime completedAt) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotService.java new file mode 100644 index 000000000..e6590b88b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotService.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderFulfillmentCompleted; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderFulfillmentSnapshotService { + private final InboundOrderMapper inbounds; + + @Transactional + public int complete(OrderFulfillmentCompleted command) { + var rows = inbounds.selectList(new LambdaQueryWrapper() + .eq(InboundOrderEntity::getTenantId, command.tenantId()) + .eq(InboundOrderEntity::getOrderId, command.orderId()) + .eq(InboundOrderEntity::getDeleted, false).orderByAsc(InboundOrderEntity::getId).last("FOR UPDATE")); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "来源订单入仓单不存在"); + if (rows.stream().anyMatch(row -> !command.orderNo().equals(row.getOrderNo()) + || !command.inboundCode().equals(row.getInboundCode()))) + throw new BizException(ResultCode.CONFLICT, "履约完成快照与来源订单标识不一致"); + if (rows.stream().anyMatch(row -> !"DELIVERED".equals(row.getProcessStatus()))) + throw new BizException(ResultCode.CONFLICT, "来源订单仍有未签收入仓单"); + return inbounds.markOrderSnapshotCompleted(command.tenantId(), command.orderId(), command.completedAt()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java index cafd14e10..9362d35b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -84,20 +84,33 @@ public class OverseasDeliveryCompletedConsumer { long sourceOrderId = positive(payload.sourceOrderId(), "来源订单 ID 无效"); if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) { var response = orderClient.complete(internalToken, new OrderFulfillmentCompletionClient.Request( - event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(), + event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(), payload.rootOrderId(), payload.deliveryNo(), payload.completedAt(), event.eventId(), event.payloadHash())); if (response == null || !response.successful() || response.data() == null - || !"COMPLETED".equals(response.data().status())) { - throw new IllegalStateException("Order 服务未确认来源订单完成"); + || !Objects.equals(response.data().orderId(), sourceOrderId) + || !Objects.equals(response.data().rootOrderId(), positive(payload.rootOrderId(), "履约根订单 ID 无效")) + || !completionStatus(response.data().rootStatus()) + || !("COMPLETED".equals(response.data().status()) + || (response.data().rootOrderId() == sourceOrderId + && Objects.equals(response.data().status(), response.data().rootStatus()) + && completionStatus(response.data().status())))) { + throw new IllegalStateException("Order 服务未确认来源订单履约事实"); } - inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId, - LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC)); + LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC); + if ("COMPLETED".equals(response.data().status())) + inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId, completedAt); + if (response.data().rootOrderId() != sourceOrderId && "COMPLETED".equals(response.data().rootStatus())) + inboundOrders.markOrderSnapshotCompleted(tenantId, response.data().rootOrderId(), completedAt); } inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(inboxRow); return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); } + private static boolean completionStatus(String status) { + return status != null && java.util.Set.of("APPROVED", "ACCEPTED", "PROCESSING", "COMPLETED").contains(status); + } + private void validate(EventEnvelope event) { event.requireValid(); if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 @@ -114,7 +127,7 @@ public class OverseasDeliveryCompletedConsumer { OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class); if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo()) || !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo()) - || !StringUtils.hasText(value.inboundCode())) throw new EventProtocolException("字段不完整"); + || !StringUtils.hasText(value.inboundCode()) || !StringUtils.hasText(value.rootOrderId())) throw new EventProtocolException("字段不完整"); return value; } catch (Exception exception) { throw new EventProtocolException("海外派送完成事件负载无效", exception); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 1d9490311..026d8a674 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -303,6 +303,29 @@ paths: schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'} responses: '200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409} + /internal/firstleg/v1/inbounds/order-fulfillment-completed: + post: + summary: 接收 Order 履约最终完成并幂等同步入仓订单状态摘要 + description: >- + 由 Order 事务 Outbox 可靠投递,签收聚合和最后有效余量取消都使用同一正式完成事实。 + 按客户、来源订单号及入仓码核验全部有效入仓已为 DELIVERED 后更新摘要;重复调用不重复递增版本。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, orderId, orderNo, inboundCode, completedAt] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + orderId: {type: integer, format: int64, minimum: 1} + orderNo: {type: string, minLength: 1} + inboundCode: {type: string, minLength: 1} + completedAt: {type: string, format: date-time} + responses: + '200': {description: data 返回实际更新入仓摘要数量,幂等重放为 0;未签收或来源不符时拒绝写入} /internal/firstleg/v1/inbounds/order-remarks-updated: post: summary: 同步 Order 权威协作备注到未冻结预装明细 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 5cd1aec90..63985272b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -39,9 +39,21 @@ class InternalInboundOrderReferenceControllerTest { private OrderDeclarationSyncService declarationSync; @MockBean private OrderRemarksSyncService remarksSync; + @MockBean private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot; @Resource private InternalInboundOrderController controller; + @Test + void completionSnapshotRequiresInternalTokenAndDelegatesExactCommand() { + var command = new com.baiyue.firstleg.dto.OrderFulfillmentCompleted(11, 21, + "FL-21", "IN-21", LocalDateTime.of(2026, 9, 30, 12, 0)); + assertThatThrownBy(() -> controller.completeFulfillmentSnapshot("wrong", command)).isInstanceOf(BizException.class); + org.mockito.Mockito.verifyNoInteractions(fulfillmentSnapshot); + when(fulfillmentSnapshot.complete(command)).thenReturn(0); + assertThat(controller.completeFulfillmentSnapshot("internal-token", command).data()).isZero(); + verify(fulfillmentSnapshot).complete(command); + } + @Test void shouldHandleCase01() { var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L, 77L, 78L)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotServiceTest.java new file mode 100644 index 000000000..8d125f39b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderFulfillmentSnapshotServiceTest.java @@ -0,0 +1,52 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderFulfillmentCompleted; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +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.*; +import static org.mockito.ArgumentMatchers.*; + +class OrderFulfillmentSnapshotServiceTest { + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "snapshot-test"), InboundOrderEntity.class); + } + @Test void completedAndReplaySyncOnlyAuthenticatedSource() { + var mapper = mock(InboundOrderMapper.class); + when(mapper.selectList(any())).thenReturn(List.of(row("FL21", "IN21", "DELIVERED"))); + when(mapper.markOrderSnapshotCompleted(eq(11L),eq(21L),any())).thenReturn(1,0); + var service = new OrderFulfillmentSnapshotService(mapper); + assertThat(service.complete(command())).isEqualTo(1); + assertThat(service.complete(command())).isZero(); + var query = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(mapper, times(2)).selectList(query.capture()); + assertThat(query.getValue().getSqlSegment()).contains("tenant_id", "order_id", "deleted", "FOR UPDATE"); + } + @Test void unDeliveredFragmentBlocksAllSnapshotWrites() { + var mapper = mock(InboundOrderMapper.class); + when(mapper.selectList(any())).thenReturn(List.of(row("FL21", "IN21", "DELIVERED"), row("FL21", "IN21", "LOADED"))); + assertThatThrownBy(() -> new OrderFulfillmentSnapshotService(mapper).complete(command())).isInstanceOf(BizException.class); + verify(mapper,never()).markOrderSnapshotCompleted(anyLong(),anyLong(),any()); + } + @Test void missingOrForeignSnapshotBlocksAllWrites() { + var mapper = mock(InboundOrderMapper.class); + when(mapper.selectList(any())).thenReturn(List.of(), List.of(row("OTHER", "IN21", "DELIVERED"))); + var service = new OrderFulfillmentSnapshotService(mapper); + assertThatThrownBy(() -> service.complete(command())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.complete(command())).isInstanceOf(BizException.class); + verify(mapper,never()).markOrderSnapshotCompleted(anyLong(),anyLong(),any()); + } + private OrderFulfillmentCompleted command() { return new OrderFulfillmentCompleted(11,21,"FL21","IN21", LocalDateTime.of(2026,9,30,12,0)); } + private InboundOrderEntity row(String orderNo, String inboundCode, String process) { + var row = new InboundOrderEntity(); row.setId(1L);row.setTenantId(11L);row.setOrderId(21L); + row.setOrderNo(orderNo);row.setInboundCode(inboundCode);row.setProcessStatus(process); return row; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java index 215339e11..e8f3091b2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -36,7 +36,7 @@ class OverseasDeliveryCompletedConsumerTest { when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0); when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success( - new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false))); + new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false, 201L, "COMPLETED"))); consumer.consume(event(json)); @@ -72,10 +72,48 @@ class OverseasDeliveryCompletedConsumerTest { verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any()); } - private EventEnvelope event(ObjectMapper json) { + @Test + void acceptsRootShipmentFactWithoutPrematureCompletedSnapshot() { + verifyCompletionResponse(new OrderFulfillmentCompletionClient.Result(201L, "PROCESSING", false, + 201L, "PROCESSING"), false, false); + } + + @Test + void marksOnlyChildWhenRootStillHasOutstandingBranch() { + verifyCompletionResponse(new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false, + 200L, "PROCESSING"), true, false); + } + + @Test + void marksChildAndRootOnLastSignedBranch() { + verifyCompletionResponse(new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false, + 200L, "COMPLETED"), true, true); + } + + private void verifyCompletionResponse(OrderFulfillmentCompletionClient.Result result, + boolean sourceCompleted, boolean rootCompleted) { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer(inbounds, + mock(InboundStatusHistoryMapper.class), mock(ContainerEventInboxMapper.class), orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("DELIVERED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success(result)); + consumer.consume(event(json, result.rootOrderId().toString())); + verify(inbounds, times(sourceCompleted ? 1 : 0)).markOrderSnapshotCompleted(eq(88L), eq(201L), any()); + verify(inbounds, times(rootCompleted ? 1 : 0)).markOrderSnapshotCompleted(eq(88L), eq(200L), any()); + } + + private EventEnvelope event(ObjectMapper json) { return event(json, "201"); } + private EventEnvelope event(ObjectMapper json, String rootOrderId) { Instant time = Instant.parse("2026-09-15T08:00:00Z"); var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11", - "101", "IN-1", "201", "FL-201", "201", "ONT8", time); + "101", "IN-1", "201", "FL-201", rootOrderId, "ONT8", time); var node = json.valueToTree(payload); return new EventEnvelope("00000000-0000-4000-8000-000000000001", EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, diff --git a/order/pom.xml b/order/pom.xml index c9a6c1cf0..82ae0dd70 100644 --- a/order/pom.xml +++ b/order/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml order-service + com.h2databaseh2test com.baiyue.sharedbaiyue-common-audit${project.version} org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 739505aa2..fec2ffc9a 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -14,6 +14,13 @@ import com.baiyue.order.dto.OrderDeclarationDtos; @FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient", url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds") public interface FirstlegInboundClient { + @PostMapping("/order-fulfillment-completed") + ApiResponse completeFulfillmentSnapshot(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody FulfillmentCompletedCommand command); + + record FulfillmentCompletedCommand(long tenantId, long orderId, String orderNo, String inboundCode, + LocalDateTime completedAt) { } + @PostMapping("/order-approved") ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderApprovedInboundCommand command); diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java index 2fccbe52a..acb108c1f 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java @@ -12,9 +12,17 @@ import java.util.Map; */ @Mapper public interface OrderMapper extends BaseMapper{ + @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0") + OrderEntity findOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE") OrderEntity lockOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND parent_order_id=#{rootOrderId} " + + "AND deleted=0 ORDER BY id FOR UPDATE") + java.util.List lockFulfillmentChildren(@Param("tenantId") long tenantId, + @Param("rootOrderId") long rootOrderId); + @Select(""" SELECT COUNT(*) AS total_submitted_orders, COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java index 47de0ff24..053862a10 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java @@ -7,4 +7,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; * 订单状态历史 Mapper,提供 {@code ord_order_status_history} 的 MyBatis-Plus 基础数据访问能力。 */ @Mapper -public interface OrderStatusHistoryMapper extends BaseMapper{} +public interface OrderStatusHistoryMapper extends BaseMapper{ + @org.apache.ibatis.annotations.Select("SELECT MAX(occurred_at) FROM ord_order_status_history " + + "WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND action='OVERSEAS_DELIVERED'") + java.time.LocalDateTime overseasDeliveredAt(@org.apache.ibatis.annotations.Param("tenantId") long tenantId, + @org.apache.ibatis.annotations.Param("orderId") long orderId); +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 2e72adc96..afc691774 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -46,6 +46,7 @@ public class OrderApplicationService { private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd"); @Resource private OrderRepository repository; + @Resource private OrderFulfillmentCompletionService fulfillmentCompletion; @Resource private ObjectMapper json; @Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderMasterDataGuard masterDataGuard; @@ -173,6 +174,7 @@ public class OrderApplicationService { @Transactional public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason, Integer expectedVersion) { + if (target == OrderStatus.CANCELLED) fulfillmentCompletion.lockCancellationRoot(tenantId, id); OrderEntity order = require(tenantId, id); if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) { throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试"); @@ -210,6 +212,7 @@ public class OrderApplicationService { if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) { writeOutbox(order, target); } + if (target == OrderStatus.CANCELLED) fulfillmentCompletion.afterCancellation(tenantId, id); } /** 提交订单的幂等入口。 */ diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java index 41ef25794..f68c73ead 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java @@ -25,42 +25,138 @@ public class OrderFulfillmentCompletionService { OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING); @Resource private OrderMapper orders; @Resource private OrderStatusHistoryMapper histories; + @Resource private com.baiyue.order.mapper.OutboxEventMapper outbox; + @Resource private com.fasterxml.jackson.databind.ObjectMapper json; @Transactional public Result complete(Request request) { long tenantId = positive(request.tenantId(), "客户 ID 无效"); long orderId = positive(request.orderId(), "订单 ID 无效"); - OrderEntity order = orders.lockOwned(tenantId, orderId); - if (order == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在"); + OrderEntity source = orders.findOwned(tenantId, orderId); + if (source == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在"); + long rootId = rootId(source); + if (positive(request.rootOrderId(), "履约根订单 ID 无效") != rootId) + throw new BizException(ResultCode.CONFLICT, "派送完成事件与履约根订单不一致"); + // All fulfillment writers serialize on the root before locking a child, matching split creation. + OrderEntity root = orders.lockOwned(tenantId, rootId); + if (root == null || !"MASTER".equals(root.getOrderRole())) + throw new BizException(ResultCode.CONFLICT, "来源订单履约根不存在或层级非法"); + OrderEntity order = orderId == rootId ? root : orders.lockOwned(tenantId, orderId); + if (order == null || rootId(order) != rootId) + throw new BizException(ResultCode.CONFLICT, "来源订单履约层级已变化"); if (!Objects.equals(order.getOrderNo(), request.orderNo()) || !Objects.equals(order.getInboundCode(), request.inboundCode())) { throw new BizException(ResultCode.CONFLICT, "派送完成事件与来源订单标识不一致"); } - if (order.getStatus() == OrderStatus.COMPLETED) { - return new Result(orderId, order.getStatus().name(), true); - } - if (!COMPLETABLE.contains(order.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus()); - } - OrderStatus from = order.getStatus(); + requireCompletable(order); + requireCompletable(root); LocalDateTime completedAt = LocalDateTime.ofInstant(request.completedAt(), ZoneOffset.UTC); + LocalDateTime ownDeliveredAt = histories.overseasDeliveredAt(tenantId, orderId); + boolean replayed = ownDeliveredAt != null; + if (!replayed) { + if (orderId == rootId) { + // MASTER also carries its first physical shipment: record that fact without completing + // the aggregate while an effective remainder/other shipment is outstanding. + record(order, order.getStatus(), "OVERSEAS_DELIVERED", request.deliveryNo(), completedAt); + } else { + finish(order, completedAt, "OVERSEAS_DELIVERED", request.deliveryNo()); + } + } + LocalDateTime rootDeliveredAt = orderId == rootId && !replayed + ? completedAt : histories.overseasDeliveredAt(tenantId, rootId); + aggregate(root, order, rootDeliveredAt, request.deliveryNo()); + return new Result(orderId, order.getStatus().name(), replayed, rootId, root.getStatus().name()); + } + + /** Acquire the same root lock before the cancellation writer takes a child row lock. */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockCancellationRoot(long tenantId, long orderId) { + OrderEntity source = orders.findOwned(tenantId, orderId); + if (source == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在"); + orders.lockOwned(tenantId, rootId(source)); + } + + /** Called in the cancellation transaction after the valid terminal branch is persisted. */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void afterCancellation(long tenantId, long orderId) { + OrderEntity source = orders.findOwned(tenantId, orderId); + if (source == null || !"CHILD".equals(source.getOrderRole())) return; + OrderEntity root = orders.lockOwned(tenantId, rootId(source)); + if (root == null || root.getStatus() == OrderStatus.CANCELLED) return; + requireCompletable(root); + aggregate(root, source, histories.overseasDeliveredAt(tenantId, root.getId()), "余量取消后履约汇总"); + } + + private void aggregate(OrderEntity root, OrderEntity order, LocalDateTime rootDeliveredAt, String deliveryNo) { + long tenantId = root.getTenantId(), rootId = root.getId(), orderId = order.getId(); + if (root.getStatus() != OrderStatus.COMPLETED && rootDeliveredAt != null) { + java.util.List children = orders.lockFulfillmentChildren(tenantId, rootId); + boolean allComplete = true; + LocalDateTime aggregateAt = rootDeliveredAt; + for (OrderEntity child : children) { + if (!"CHILD".equals(child.getOrderRole()) || !Objects.equals(child.getRootOrderId(), rootId)) + throw new BizException(ResultCode.CONFLICT, "余量订单履约层级非法"); + // CANCELLED is the only explicit non-effective terminal branch. Unknown, draft, + // rejected or pending statuses must not silently disappear from this gate. + if (child.getStatus() == OrderStatus.CANCELLED) continue; + OrderStatus status = child.getId().equals(orderId) ? order.getStatus() : child.getStatus(); + if (status != OrderStatus.COMPLETED) allComplete = false; + LocalDateTime childAt = child.getId().equals(orderId) ? order.getCompletedAt() : child.getCompletedAt(); + if (childAt != null && childAt.isAfter(aggregateAt)) aggregateAt = childAt; + } + if (allComplete) finish(root, aggregateAt, "FULFILLMENT_COMPLETED", deliveryNo); + } + } + + private static long rootId(OrderEntity order) { + if ("MASTER".equals(order.getOrderRole())) return order.getId(); + if (!"CHILD".equals(order.getOrderRole()) || order.getRootOrderId() == null + || !Objects.equals(order.getRootOrderId(), order.getParentOrderId())) + throw new BizException(ResultCode.CONFLICT, "来源订单履约层级非法"); + return order.getRootOrderId(); + } + + private static void requireCompletable(OrderEntity order) { + if (order.getStatus() != OrderStatus.COMPLETED && !COMPLETABLE.contains(order.getStatus())) + throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus()); + } + + private void finish(OrderEntity order, LocalDateTime completedAt, String action, String deliveryNo) { + if (order.getStatus() == OrderStatus.COMPLETED) return; int version = order.getVersion() == null ? 0 : order.getVersion(); int updated = orders.update(null, new LambdaUpdateWrapper() - .eq(OrderEntity::getTenantId, tenantId).eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getTenantId, order.getTenantId()).eq(OrderEntity::getId, order.getId()) .eq(OrderEntity::getVersion, version).eq(OrderEntity::getDeleted, false) .set(OrderEntity::getStatus, OrderStatus.COMPLETED) .set(OrderEntity::getCompletedAt, completedAt) .set(OrderEntity::getUpdatedBy, 0L).set(OrderEntity::getUpdatedAt, completedAt) .setSql("version=version+1")); if (updated != 1) throw new BizException(ResultCode.CONFLICT, "来源订单版本已变化,请重试"); + record(order, OrderStatus.COMPLETED, action, deliveryNo, completedAt); + order.setStatus(OrderStatus.COMPLETED); order.setCompletedAt(completedAt); order.setVersion(version + 1); + com.baiyue.order.entity.OutboxEventEntity event = new com.baiyue.order.entity.OutboxEventEntity(); + event.setId(IdWorker.getId()); event.setTenantId(order.getTenantId()); + event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId()); + event.setEventType("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED"); + try { + event.setPayloadJson(json.writeValueAsString(new com.baiyue.order.client.FirstlegInboundClient + .FulfillmentCompletedCommand(order.getTenantId(), order.getId(), order.getOrderNo(), + order.getInboundCode(), completedAt))); + } catch (com.fasterxml.jackson.core.JsonProcessingException failure) { + throw new BizException(ResultCode.INTERNAL_ERROR, "履约完成快照序列化失败"); + } + event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (outbox.insert(event) != 1) throw new BizException(ResultCode.CONFLICT, "履约完成事件写入失败"); + } + + private void record(OrderEntity order, OrderStatus to, String action, String deliveryNo, LocalDateTime at) { OrderStatusHistoryEntity history = new OrderStatusHistoryEntity(); - history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setOrderId(orderId); - history.setFromStatus(from); history.setToStatus(OrderStatus.COMPLETED); - history.setAction("OVERSEAS_DELIVERED"); - history.setReason("海外派送单 " + request.deliveryNo() + " 已签收"); - history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE"); - history.setOccurredAt(completedAt); histories.insert(history); - return new Result(orderId, OrderStatus.COMPLETED.name(), false); + history.setId(IdWorker.getId()); history.setTenantId(order.getTenantId()); history.setOrderId(order.getId()); + history.setFromStatus(order.getStatus()); history.setToStatus(to); history.setAction(action); + history.setReason("海外派送单 " + deliveryNo + " 已签收"); + history.setOperatorId(0L); history.setOperatorName("OVERSEAS_DELIVERY_EVENT"); + history.setOccurredAt(at); + if (histories.insert(history) != 1) throw new BizException(ResultCode.CONFLICT, "派送完成历史写入失败"); } private static long positive(String value, String message) { @@ -69,8 +165,8 @@ public class OrderFulfillmentCompletionService { throw new BizException(ResultCode.BAD_REQUEST, message); } - public record Request(String tenantId, String orderId, String orderNo, String inboundCode, + public record Request(String tenantId, String orderId, String orderNo, String inboundCode, String rootOrderId, String deliveryNo, java.time.Instant completedAt, String eventId, String payloadHash) { } - public record Result(Long orderId, String status, boolean replayed) { } + public record Result(Long orderId, String status, boolean replayed, Long rootOrderId, String rootStatus) { } } diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 37cd57cbf..69a4beec5 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -38,6 +38,10 @@ public class OrderInboundOutboxPublisher { publishDeclarationUpdate(event); continue; } + if ("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED".equals(event.getEventType())) { + publishFulfillmentCompletion(event); + continue; + } boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType()); if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { failPermanently(event, "unsupported order event"); @@ -87,6 +91,24 @@ public class OrderInboundOutboxPublisher { } } + private void publishFulfillmentCompletion(OutboxEventEntity event) { + try { + var command = json.readValue(event.getPayloadJson(), FirstlegInboundClient.FulfillmentCompletedCommand.class); + if (command == null || event.getTenantId() == null || event.getAggregateId() == null + || event.getTenantId() != command.tenantId() || event.getAggregateId() != command.orderId()) { + failPermanently(event, "completion event ownership does not match payload"); return; + } + var response = client.completeFulfillmentSnapshot(internalToken, command); + if (response == null || !response.successful() || response.data() == null) + throw new IllegalStateException("头程履约完成快照同步失败"); + repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now()); + } catch (com.fasterxml.jackson.core.JsonProcessingException invalid) { + failPermanently(event, "invalid completion event payload"); + } catch (Exception failure) { + repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10)); + } + } + private void publishDeclarationUpdate(OutboxEventEntity event) { try { OrderDeclarationDtos.UpdatedEvent command = json.readValue( diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 9a84ea738..b27d06374 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -9,10 +9,13 @@ paths: /internal/order/v1/fulfillment-completions: post: security: [] - summary: Firstleg 回传海外派送完成并结束对应订单 + summary: Firstleg 回传来源实装签收并聚合履约根订单完成 description: >- 仅接受内部服务令牌。请求必须携带海外事件已验证的来源订单 ID、订单号、入仓码、 - FBA 派送单号和完成时间;只推进该派送单关联的订单,COMPLETED 重放返回成功。 + FBA 派送单号和完成时间。来源根订单先持久化自身实装签收事实,只有全部有效余量子单 + 已完成才完成根订单;来源子单独立完成并在根锁下重新评估聚合。明确 CANCELLED 子单 + 不参与完成门禁,其他未完成状态均阻断。重复事实不得重复写历史。响应明确返回来源状态 + status 和履约根 rootOrderId/rootStatus;根尚未聚合完成时允许保留其原状态。 parameters: - name: X-Internal-Service-Token in: header @@ -24,18 +27,36 @@ paths: application/json: schema: type: object - required: [tenantId, orderId, orderNo, inboundCode, deliveryNo, completedAt, eventId, payloadHash] + required: [tenantId, orderId, orderNo, inboundCode, rootOrderId, deliveryNo, completedAt, eventId, payloadHash] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} inboundCode: {type: string} + rootOrderId: {type: string, pattern: '^[1-9][0-9]*$'} deliveryNo: {type: string} completedAt: {type: string, format: date-time} eventId: {type: string, format: uuid} payloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} responses: - '200': {description: 对应订单已完成或幂等重放成功} + '200': + description: 来源实装签收事实已保存,返回来源和履约根各自状态 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string} + data: + type: object + required: [orderId, status, replayed, rootOrderId, rootStatus] + properties: + orderId: {type: integer, format: int64} + status: {type: string, enum: [APPROVED, ACCEPTED, PROCESSING, COMPLETED]} + replayed: {type: boolean} + rootOrderId: {type: integer, format: int64} + rootStatus: {type: string, enum: [APPROVED, ACCEPTED, PROCESSING, COMPLETED]} '403': {description: 内部服务令牌无效} /internal/order/v1/cargoes/{tenantId}/{orderId}: get: diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 8a47198db..490879d19 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -37,6 +37,7 @@ import static org.mockito.Mockito.*; @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class OrderApplicationServiceStateTest { @MockBean private OrderRepository contextRepository; + @MockBean private OrderFulfillmentCompletionService contextCompletion; @MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @@ -48,6 +49,24 @@ class OrderApplicationServiceStateTest { static class TestConfig { @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } } + @Test + void cancellationLocksRootBeforeSourceWriteAndThenAggregates() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(OrderStatus.APPROVED); + order.setOrderNo("FL-1"); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + OrderFulfillmentCompletionService completion = mock(OrderFulfillmentCompletionService.class); + var app = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)); + ReflectionTestUtils.setField(app, "fulfillmentCompletion", completion); + app.transit(10001, 2, "u", 1, OrderStatus.CANCELLED, "取消余量"); + var sequence = org.mockito.Mockito.inOrder(repository, completion); + sequence.verify(completion).lockCancellationRoot(10001, 1); + sequence.verify(repository).findOwned(10001, 1); + sequence.verify(repository).saveOrder(10001, order); + sequence.verify(completion).afterCancellation(10001, 1); + } + @Test void unapprovedTenantCanCreateDraft() { OrderRepository repository = mock(OrderRepository.class); diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java index 8651dbc72..c6c9304f9 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java @@ -29,6 +29,7 @@ import static org.mockito.Mockito.when; class OrderFulfillmentCompletionServiceTest { @Mock private OrderMapper orders; @Mock private OrderStatusHistoryMapper histories; + @Mock private com.baiyue.order.mapper.OutboxEventMapper outbox; @InjectMocks private OrderFulfillmentCompletionService service; @BeforeAll @@ -45,19 +46,26 @@ class OrderFulfillmentCompletionServiceTest { order.setTenantId(11L); order.setOrderNo("FL201"); order.setInboundCode("IN-201"); + order.setOrderRole("MASTER"); order.setStatus(OrderStatus.PROCESSING); order.setVersion(7); + when(orders.findOwned(11L, 201L)).thenReturn(order); when(orders.lockOwned(11L, 201L)).thenReturn(order); when(orders.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1); when(histories.insert(any(OrderStatusHistoryEntity.class))).thenReturn(1); + when(outbox.insert(any(com.baiyue.order.entity.OutboxEventEntity.class))).thenReturn(1); + org.springframework.test.util.ReflectionTestUtils.setField(service, "json", + new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); var result = service.complete(new OrderFulfillmentCompletionService.Request( - "11", "201", "FL201", "IN-201", "DL-201", Instant.parse("2026-09-15T12:00:00Z"), + "11", "201", "FL201", "IN-201", "201", "DL-201", Instant.parse("2026-09-15T12:00:00Z"), "event-201", "sha256:test")); assertThat(result.status()).isEqualTo("COMPLETED"); ArgumentCaptor history = ArgumentCaptor.forClass(OrderStatusHistoryEntity.class); - verify(histories).insert(history.capture()); - assertThat(history.getValue().getAction()).isEqualTo("OVERSEAS_DELIVERED").hasSizeLessThanOrEqualTo(24); + verify(histories, org.mockito.Mockito.times(2)).insert(history.capture()); + assertThat(history.getAllValues()).extracting(OrderStatusHistoryEntity::getAction) + .containsExactly("OVERSEAS_DELIVERED", "FULFILLMENT_COMPLETED"); + assertThat(history.getAllValues()).allSatisfy(row -> assertThat(row.getAction()).hasSizeLessThanOrEqualTo(24)); } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionTransactionTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionTransactionTest.java new file mode 100644 index 000000000..fb4c2e312 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionTransactionTest.java @@ -0,0 +1,222 @@ +package com.baiyue.order.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.mapper.OrderStatusHistoryMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.*; + +/** Runs production Mapper SQL and service writes with a real local transaction manager. */ +class OrderFulfillmentCompletionTransactionTest { + private JdbcTemplate db; + private OrderFulfillmentCompletionService service; + private org.springframework.transaction.support.TransactionTemplate transactions; + private static final Instant ROOT_AT = Instant.parse("2026-09-30T08:00:00Z"); + + @BeforeEach + void prepare() throws Exception { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000"); + db = new JdbcTemplate(source); + db.execute("CREATE TABLE ord_order (id BIGINT PRIMARY KEY, tenant_id BIGINT, order_no VARCHAR(80), " + + "inbound_code VARCHAR(80), order_role VARCHAR(16), parent_order_id BIGINT, root_order_id BIGINT, " + + "status VARCHAR(20), completed_at TIMESTAMP, version INT, deleted BOOLEAN DEFAULT FALSE, " + + "updated_by BIGINT, updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ord_order_status_history (id BIGINT PRIMARY KEY, tenant_id BIGINT, order_id BIGINT, " + + "from_status VARCHAR(20), to_status VARCHAR(20), action VARCHAR(24), reason VARCHAR(200), " + + "operator_id BIGINT, operator_name VARCHAR(80), occurred_at TIMESTAMP)"); + db.execute("CREATE TABLE ord_outbox_event (id BIGINT PRIMARY KEY, tenant_id BIGINT, aggregate_type VARCHAR(80), " + + "aggregate_id BIGINT, event_type VARCHAR(80), payload_json CLOB, publish_status VARCHAR(20), " + + "retry_count INT, created_at TIMESTAMP)"); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(OrderMapper.class); + configuration.addMapper(OrderStatusHistoryMapper.class); + configuration.addMapper(com.baiyue.order.mapper.OutboxEventMapper.class); + MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(source); factory.setConfiguration(configuration); + SqlSessionTemplate session = new SqlSessionTemplate(factory.getObject()); + OrderFulfillmentCompletionService target = new OrderFulfillmentCompletionService(); + ReflectionTestUtils.setField(target, "orders", session.getMapper(OrderMapper.class)); + ReflectionTestUtils.setField(target, "histories", session.getMapper(OrderStatusHistoryMapper.class)); + ReflectionTestUtils.setField(target, "outbox", session.getMapper(com.baiyue.order.mapper.OutboxEventMapper.class)); + ReflectionTestUtils.setField(target, "json", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + transactions = new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(source)); + ProxyFactory proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), + new AnnotationTransactionAttributeSource())); + service = (OrderFulfillmentCompletionService) proxy.getProxy(); + master(201, 11); + } + + @Test + void rootOwnShipmentKeepsRootOpenUntilAllChildrenDeliverAndReplaysDoNotRewrite() { + child(202, "APPROVED"); child(203, "ACCEPTED"); + var root = complete(201, ROOT_AT); + assertThat(root.status()).isEqualTo("PROCESSING"); + assertThat(root.rootStatus()).isEqualTo("PROCESSING"); + assertThat(history(201, "OVERSEAS_DELIVERED")).isEqualTo(1); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero(); + assertThat(complete(201, ROOT_AT).replayed()).isTrue(); + complete(203, ROOT_AT.plusSeconds(20)); + assertThat(status(201)).isEqualTo("PROCESSING"); + var last = complete(202, ROOT_AT.plusSeconds(10)); + assertThat(last.rootOrderId()).isEqualTo(201L); + assertThat(last.rootStatus()).isEqualTo("COMPLETED"); + assertThat(db.queryForObject("SELECT completed_at FROM ord_order WHERE id=201", java.sql.Timestamp.class) + .toLocalDateTime().toInstant(java.time.ZoneOffset.UTC)).isEqualTo(ROOT_AT.plusSeconds(20)); + complete(202, ROOT_AT.plusSeconds(10)); complete(203, ROOT_AT.plusSeconds(20)); complete(201, ROOT_AT); + assertThat(history(201, "OVERSEAS_DELIVERED")).isEqualTo(1); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1); + assertThat(history(202, "OVERSEAS_DELIVERED")).isEqualTo(1); + assertThat(history(203, "OVERSEAS_DELIVERED")).isEqualTo(1); + } + + @Test + void allChildrenFirstCannotCompleteRootWithoutItsOwnSignedFact() { + child(202, "APPROVED"); child(203, "APPROVED"); + complete(202, ROOT_AT.plusSeconds(10)); complete(203, ROOT_AT.plusSeconds(20)); + assertThat(status(201)).isEqualTo("PROCESSING"); + assertThat(history(201, "OVERSEAS_DELIVERED")).isZero(); + assertThat(complete(201, ROOT_AT).status()).isEqualTo("COMPLETED"); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1); + } + + @Test + void onlyExplicitCancelledAndDeletedChildrenAreNonEffective() { + child(202, "CANCELLED"); child(203, "DRAFT"); + db.update("UPDATE ord_order SET deleted=TRUE WHERE id=203"); + master(900, 12); + db.update("INSERT INTO ord_order (id,tenant_id,order_no,inbound_code,order_role,parent_order_id,root_order_id,status,version) " + + "VALUES (901,12,'OTHER','OTHER-IN','CHILD',201,201,'DRAFT',0)"); + assertThat(complete(201, ROOT_AT).status()).isEqualTo("COMPLETED"); + assertThatThrownBy(() -> complete(202, ROOT_AT)).isInstanceOf(BizException.class); + assertThat(history(202, "OVERSEAS_DELIVERED")).isZero(); + } + + @Test + void draftOrRejectedEffectiveBranchBlocksAggregate() { + child(202, "REJECTED"); + assertThat(complete(201, ROOT_AT).rootStatus()).isEqualTo("PROCESSING"); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero(); + } + + @Test + void malformedChildIdentityRollsBackNewFactAndChildCompletion() { + child(202, "APPROVED"); + db.update("UPDATE ord_order SET root_order_id=999 WHERE id=202"); + assertThatThrownBy(() -> complete(201, ROOT_AT)).isInstanceOf(BizException.class); + assertThat(history(201, "OVERSEAS_DELIVERED")).isZero(); + assertThat(status(201)).isEqualTo("PROCESSING"); + assertThatThrownBy(() -> complete(202, ROOT_AT)).isInstanceOf(BizException.class); + assertThat(status(202)).isEqualTo("APPROVED"); + } + + @Test + void failedAggregateUpdateRollsBackSourceCompletionAndHistory() { + child(202, "APPROVED"); + complete(201, ROOT_AT); + db.execute("ALTER TABLE ord_order ADD CONSTRAINT refuse_root_completion " + + "CHECK (id<>201 OR status<>'COMPLETED')"); + assertThatThrownBy(() -> complete(202, ROOT_AT.plusSeconds(10))).isInstanceOf(RuntimeException.class); + assertThat(status(202)).isEqualTo("APPROVED"); + assertThat(history(202, "OVERSEAS_DELIVERED")).isZero(); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero(); + } + + @Test + void concurrentLastChildrenSerializeOnRootAndProduceSingleFinalHistory() throws Exception { + child(202, "APPROVED"); child(203, "APPROVED"); + complete(201, ROOT_AT); + ExecutorService workers = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future first = workers.submit(() -> { await(start); complete(202, ROOT_AT.plusSeconds(10)); }); + Future second = workers.submit(() -> { await(start); complete(203, ROOT_AT.plusSeconds(20)); }); + start.countDown(); first.get(15, TimeUnit.SECONDS); second.get(15, TimeUnit.SECONDS); + } finally { workers.shutdownNow(); } + assertThat(status(201)).isEqualTo("COMPLETED"); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1); + assertThat(history(202, "OVERSEAS_DELIVERED")).isEqualTo(1); + assertThat(history(203, "OVERSEAS_DELIVERED")).isEqualTo(1); + } + + @Test + void lastRemainderCancellationFinishesSignedRootAndPublishesSnapshotExactlyOnce() { + child(202, "APPROVED"); + complete(201, ROOT_AT); + transactions.executeWithoutResult(tx -> { + service.lockCancellationRoot(11, 202); + db.update("UPDATE ord_order SET status='CANCELLED' WHERE id=202"); + service.afterCancellation(11, 202); + }); + assertThat(status(201)).isEqualTo("COMPLETED"); + transactions.executeWithoutResult(tx -> service.afterCancellation(11, 202)); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ord_outbox_event WHERE aggregate_id=201", Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT payload_json FROM ord_outbox_event WHERE aggregate_id=201", String.class)) + .contains("FL201", "IN-201"); + } + + @Test + void cancellingAllChildrenWithoutRootSignedFactCannotCompleteRoot() { + child(202, "APPROVED"); + transactions.executeWithoutResult(tx -> { + service.lockCancellationRoot(11, 202); + db.update("UPDATE ord_order SET status='CANCELLED' WHERE id=202"); + service.afterCancellation(11, 202); + }); + assertThat(status(201)).isEqualTo("PROCESSING"); + assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ord_outbox_event", Integer.class)).isZero(); + } + + @Test + void foreignCustomerOrWrongSnapshotCannotWrite() { + assertThatThrownBy(() -> service.complete(new OrderFulfillmentCompletionService.Request( + "12", "201", "FL201", "IN-201", "201", "DL201", ROOT_AT, "event", "hash"))) + .isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.complete(new OrderFulfillmentCompletionService.Request( + "11", "201", "WRONG", "IN-201", "201", "DL201", ROOT_AT, "event", "hash"))) + .isInstanceOf(BizException.class); + assertThat(history(201, "OVERSEAS_DELIVERED")).isZero(); + } + + private void master(long id, long tenant) { + db.update("INSERT INTO ord_order (id,tenant_id,order_no,inbound_code,order_role,status,version) " + + "VALUES (?,?,?,?,'MASTER','PROCESSING',0)", id, tenant, "FL"+id, "IN-"+id); + } + private void child(long id, String status) { + db.update("INSERT INTO ord_order (id,tenant_id,order_no,inbound_code,order_role,parent_order_id,root_order_id,status,version) " + + "VALUES (?,11,?,?,'CHILD',201,201,?,0)", id, "FL"+id, "IN-"+id, status); + } + private OrderFulfillmentCompletionService.Result complete(long id, Instant at) { + return service.complete(new OrderFulfillmentCompletionService.Request("11", Long.toString(id), + "FL"+id, "IN-"+id, "201", "DL"+id, at, "event-"+id, "hash-"+id)); + } + private String status(long id) { return db.queryForObject("SELECT status FROM ord_order WHERE id=?", String.class, id); } + private int history(long id, String action) { + return db.queryForObject("SELECT COUNT(*) FROM ord_order_status_history WHERE order_id=? AND action=?", + Integer.class, id, action); + } + private static void await(CountDownLatch latch) { + try { latch.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new RuntimeException(interrupted); } + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 6efebce49..e8f6947de 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -37,6 +37,8 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(classes = {OrderApplicationService.class, OrderIdempotencyServiceTest.TestConfig.class}) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class OrderIdempotencyServiceTest { + @MockBean private OrderFulfillmentCompletionService fulfillmentCompletion; + @MockBean private OrderRepository contextRepository; @MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderMasterDataGuard contextMasterData; diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index a339603dd..9d9501dd5 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -206,6 +206,41 @@ class OrderInboundOutboxPublisherTest { verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any()); } + @Test + void completionOutboxPublishesAcknowledgedSnapshotIncludingIdempotentZeroUpdate() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED", 11L, 21L, + completionPayload(11, 21))); + when(fixture.client.completeFulfillmentSnapshot(eq("internal-token"), any())).thenReturn(ApiResponse.success(0)); + fixture.publisher.publishApprovedOrders(); + verify(fixture.client).completeFulfillmentSnapshot(eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(command -> command.orderId()==21 && command.inboundCode().equals("IN-21"))); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any()); + } + + @Test + void completionOutboxFailureRetriesOriginalEventAndDoesNotPublish() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED", 11L, 21L, + completionPayload(11, 21))); + when(fixture.client.completeFulfillmentSnapshot(eq("internal-token"), any())).thenThrow(new IllegalStateException("timeout")); + fixture.publisher.publishApprovedOrders(); + verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any()); + verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any()); + } + + @Test + void completionOutboxRejectsForeignPayloadBeforeCallingFirstleg() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED", 11L, 21L, + completionPayload(12, 21))); + fixture.publisher.publishApprovedOrders(); + verifyNoInteractions(fixture.client); + verify(fixture.repository).markOutboxFailed(11L, 41L); + } + + private String completionPayload(long tenant, long order) { + return "{\"tenantId\":"+tenant+",\"orderId\":"+order + +",\"orderNo\":\"FL-21\",\"inboundCode\":\"IN-21\",\"completedAt\":\"2026-09-30T08:00:00\"}"; + } + private Fixture fixture(OutboxEventEntity event) { return fixture(event, "BUY_DECLARATION"); } @@ -242,7 +277,7 @@ class OrderInboundOutboxPublisherTest { ReflectionTestUtils.setField(publisher, "repository", repository); ReflectionTestUtils.setField(publisher, "client", client); ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient); - ReflectionTestUtils.setField(publisher, "json", new ObjectMapper()); + ReflectionTestUtils.setField(publisher, "json", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(publisher, "internalToken", "internal-token"); return new Fixture(publisher, repository, client, pricingClient); } diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index 49d6131eb..64f0e0cf4 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -16,6 +16,8 @@ import static org.mockito.Mockito.verify; @SpringJUnitConfig(classes = {OrderApplicationService.class, PlatformOrderPageServiceTest.TestConfig.class}) class PlatformOrderPageServiceTest { + @MockBean private OrderFulfillmentCompletionService fulfillmentCompletion; + @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index 6ce84eb99..7e64a6269 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -19,6 +19,8 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderCountServiceTest.TestConfig.class}) class TenantOrderCountServiceTest { + @MockBean private OrderFulfillmentCompletionService fulfillmentCompletion; + @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java index a9186caca..9bee3e435 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java @@ -23,6 +23,8 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class}) class TenantOrderStatisticsServiceTest { + @MockBean private OrderFulfillmentCompletionService fulfillmentCompletion; + @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; -- 2.54.0 From 041a27c3258b7c25153c54a21a90d4f6044050c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 13:50:13 +0800 Subject: [PATCH 034/176] test(fulfillment): reconcile frozen PDA sources and dispatch departure gate --- ...se5-overseas-fulfillment-contract.test.mjs | 128 ++++++++++-- .../phase5-overseas-fulfillment.mjs | 186 +++++++++++++----- 2 files changed, 248 insertions(+), 66 deletions(-) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 0313f5969..6c61baf67 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,5 @@ import test from 'node:test' -import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, verifyPartialOrderCompletion, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, verifyDepartureReadiness, verifyPartialOrderCompletion, completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -173,13 +173,13 @@ test('same dispatch-return exception reaches actual restored inventory and retri customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : []) - : [{ id: 'OLD', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', + : [{ id: '61', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletNo: 'SOURCE', totalPackages: 8, availablePackages: 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }, ...(completed ? [{ id: '3', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])]) - if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100', + if (path.includes('/inventory/balances')) { const balance = { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8, - availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] }) + availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }; return ok(path.includes('/balances/1?') ? balance : { total: 1, records: [balance] }) } if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2', customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1', outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] }) @@ -198,7 +198,7 @@ test('same dispatch-return exception reaches actual restored inventory and retri outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] }) }) context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) } - context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } const resume = initialStatus === 'PROCESSING' ? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }) @@ -218,7 +218,7 @@ test('same dispatch-return exception reaches actual restored inventory and retri test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => { const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 }, returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })) - context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } assert.equal(await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null) assert.equal(context.calls.length, 1) @@ -227,18 +227,24 @@ test('PROCESSING continuation without the original pallet and evidence remains b test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => { const context = contextFor((method, path) => { - if (path.includes('/inventory/')) return ok({ records: [], total: 0 }) + if (path.includes('/inventory/ledger')) return ok({ records: [], total: 0 }) + if (path.includes('/inventory/balances')) { + const balance = { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', totalPackages: 1, availablePackages: 1, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 } + return ok(path.includes('/positions') ? [{ ...balance, id: '61', balanceId: '1', inboundCode: 'IN-A' }] + : path.includes('/balances/1?') ? balance : { records: [balance], total: 1 }) + } if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 }) if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } } return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 }, returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }) }) - context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } assert.equal(await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null) - assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 2) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 6) assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2) assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed)) assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') @@ -321,13 +327,13 @@ function inventoryFixture() { } function inventoryContext(fixture) { return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions) - : ok({ records: [fixture.balance], total: 1 })) + : path.includes('/balances/1?') ? ok(fixture.balance) : ok({ records: [fixture.balance], total: 1 })) } test('inventory evidence resolves missing Balance inboundCode through exact positions and retains an independent shared balance', async () => { const fixture = inventoryFixture() const context = inventoryContext(fixture) - const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock') + const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock', ['1']) assert.equal(snapshot.length, 1) assert.equal(snapshot[0].id, '1') assert.equal(snapshot[0].totalPackages, 7) @@ -342,7 +348,9 @@ test('inventory evidence resolves missing Balance inboundCode through exact posi const childOnly = inventoryFixture() childOnly.positions = [childOnly.positions[1]] childOnly.balance.totalPackages = 2; childOnly.balance.availablePackages = 2 - assert.deepEqual(await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock'), []) + const childSnapshot = await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock', ['1']) + assert.equal(childSnapshot[0].totalPackages, 0) + assert.deepEqual(childSnapshot[0].matchingPositionIds, []) }) test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => { @@ -361,24 +369,110 @@ test('inventory evidence rejects foreign balances and positions, mismatched bala ]) { const fixture = inventoryFixture(); mutate(fixture) const context = inventoryContext(fixture) - assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null) + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock', ['1']), null) assert.ok(context.assertions.some(row => !row.passed)) } }) test('inventory evidence captures zero drift independently for every balance bucket, position identity, and unselected sibling position', async () => { const fixture = inventoryFixture() - const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock') + const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock', ['1']) for (const field of ['inTransitPackages', 'procurementPackages']) { const changed = inventoryFixture(); changed.balance[field] += 1 - assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original) + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original) } for (const field of ['palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'inboundOrderId']) { const changed = inventoryFixture(); changed.positions[2][field] = 'DIFFERENT' - assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original) + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original) } for (const field of ['holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']) { const changed = inventoryFixture(); changed.positions[2][field] += 1; changed.balance[field] += 1 - assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original) + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original) } }) + +test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => { + for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [ + ['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true], + ['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false], + ['PICKING', true, 'READY_TO_DEPART', 'DS-1', false], + ['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false], + ['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false], + ['COMPLETED', true, 'DEPARTED', 'DS-1', false], + ['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false], + ]) { + const context = contextFor((method, path) => path.startsWith('/api/pda/') + ? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus })) + assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected) + assert.equal(context.calls.length, 2) + assert.equal(context.calls.some(call => call.method !== 'GET'), false) + assert.equal(context.assertions.some(row => !row.passed), !expected) + } +}) + + +test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => { + const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [ + { positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] } + assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'], + sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] }) + assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null) + assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null) + assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null) + let shipped = false + const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1', + destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6, + availablePackages: balanceId === '1' ? 3 : 6, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }) + const context = contextFor((method, path) => { + assert.equal(new URL(`https://test.invalid${path}`).searchParams.has('inboundCode'), false) + const match = path.match(/balances\/(1|2)(\/positions)?\?/) + if (!match) return ok({ records: [{ ...source(shipped ? '99' : '1'), totalPackages: shipped ? 3 : 12, availablePackages: shipped ? 3 : 12 }], total: 1 }) + const balance = source(match[1]) + if (match[2]) return ok([{ ...balance, id: match[1] === '1' ? '11' : '12', balanceId: match[1], inboundCode: 'IN-A', + totalPackages: shipped ? 0 : balance.totalPackages, availablePackages: shipped ? 0 : balance.availablePackages }]) + return shipped ? { http: 404, body: { code: 'A0404' } } : ok(balance) + }) + const frozenSources = dispatchInventorySources(task, 'DS-1', 'WH-1') + const frozenIds = frozenSources.sourceBalanceIds + const before = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', frozenIds) + assert.equal(inventorySourcesPresent(before, frozenSources.sourcePositionBalances), true) + assert.equal(inventorySourcesPresent(before, [{ positionId: '11', balanceId: '2' }]), false) + assert.equal(inventorySourcesPresent(before, [{ positionId: 'UNKNOWN', balanceId: '1' }]), false) + assert.equal(before.reduce((sum, row) => sum + row.totalPackages, 0), 9) + assert.equal(before.reduce((sum, row) => sum + row.balance.totalPackages, 0), 9) + assert.equal(before[0].globalGroups[0].totalPackages, 12) + shipped = true + const after = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', frozenIds) + assert.equal(inventorySourcesPresent(after, frozenSources.sourcePositionBalances), true) + assert.equal(after.reduce((sum, row) => sum + row.totalPackages, 0), 0) + assert.equal(after.reduce((sum, row) => sum + row.balance.totalPackages, 0), 0) + assert.equal(after[0].globalGroups[0].totalPackages, 3) + assert.deepEqual(after, await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'replay', frozenIds)) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +test('source snapshot never treats unknown or incompletely proven raw 404 as zero inventory', async () => { + const missing = { http: 404, body: { code: 'A0404' } } + for (const mode of ['unknown', 'positions-failure', 'position-nonzero', 'group-not-decreased']) { + let absent = false + const fixture = inventoryFixture() + fixture.balance.inTransitPackages = 0; fixture.balance.procurementPackages = 0 + const context = contextFor((method, path) => { + if (path.includes('/positions')) { + if (absent && mode === 'positions-failure') return missing + return ok(fixture.positions.map(row => absent && mode !== 'position-nonzero' + ? { ...row, totalPackages: 0, availablePackages: 0 } : row)) + } + if (path.includes('/balances/1?')) return absent ? missing : ok(fixture.balance) + return ok({ total: absent && mode !== 'group-not-decreased' ? 0 : 1, + records: absent && mode !== 'group-not-decreased' ? [] : [fixture.balance] }) + }) + if (mode !== 'unknown') assert.ok(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', ['1'])) + absent = true + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', ['1']), null) + assert.ok(context.assertions.some(row => !row.passed)) + } + const context = contextFor(() => { throw new Error('Missing fixed IDs cannot issue HTTP reads') }) + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 5fc62a177..c5edd0bd8 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -101,53 +101,112 @@ export function inventoryQuantitySnapshot(rows) { ])).sort((a, b) => a.id.localeCompare(b.id)) } -export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { +const balanceDimension = row => JSON.stringify([id(row.customerId), id(row.overseasWarehouseId), + id(row.destinationWarehouseId), id(row.destinationWarehouseCode), id(row.skuCode)]) +const validBuckets = (row, fields) => fields.every(field => row[field] !== null && row[field] !== undefined + && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0) + +export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name, sourceBalanceIds = []) { const codes = new Set(inboundCodes) - const candidates = new Map() - for (const inboundCode of codes) { - const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, - { customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`) - if (!current) return null - for (const balance of current) { - const owned = id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId - const quantities = BALANCE_BUCKETS.every(field => balance[field] !== null && balance[field] !== undefined && balance[field] !== '' && Number.isSafeInteger(Number(balance[field])) && Number(balance[field]) >= 0) - const previous = candidates.get(id(balance.id)) - const stable = !previous || JSON.stringify(inventoryQuantitySnapshot([previous])) === JSON.stringify(inventoryQuantitySnapshot([balance])) - context.assert(MODULE, `${name}余额客户仓库数量及重复读取一致`, null, owned && quantities && stable, - `balanceId=${id(balance.id)}, owned=${owned}, quantities=${quantities}, stable=${stable}`, OPTIONS) - if (!owned || !quantities || !stable) return null - candidates.set(id(balance.id), balance) - } + const sourceIds = [...new Set(sourceBalanceIds.map(id))].sort() + if (!sourceIds.length || sourceIds.some(value => !/^[1-9][0-9]*$/.test(value))) { + blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源') + return null } + const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, + { customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`) + if (!groups) return null + const groupValid = groups.every(row => id(row.customerId) === customerId + && id(row.overseasWarehouseId) === warehouseId && validBuckets(row, BALANCE_BUCKETS)) + && new Set(groups.map(balanceDimension)).size === groups.length + context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS) + if (!groupValid) return null + context.context ||= {} + context.context.phase5InventorySources ||= {} + const frozen = context.context.phase5InventorySources const snapshot = [] - for (const balance of candidates.values()) { - const current = await platform(context, 'GET', `${name}读取余额实际全部位置`, - query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId })) + for (const balanceId of sourceIds) { + const sourceKey = `${customerId}:${warehouseId}:${balanceId}` + const detailResponse = await context.request('GET', query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}`, { customerId })) + const missingZero = detailResponse.http === 404 || (detailResponse.http === 200 && code(detailResponse) === 'A0404') + if (!success(detailResponse) && !missingZero) { + context.apiSuccess(MODULE, `${name}读取冻结真实余额`, detailResponse, trace(detailResponse), OPTIONS) + return null + } + const current = await platform(context, 'GET', `${name}读取冻结余额实际全部位置`, + query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { customerId })) const positions = current.data const valid = success(current.response) && Array.isArray(positions) && new Set(positions.map(row => id(row.id))).size === positions.length - && positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === id(balance.id) + && positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === balanceId && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId - && nonBlank(row.inboundCode) - && POSITION_BUCKETS.every(field => row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0)) + && nonBlank(row.inboundCode) && validBuckets(row, POSITION_BUCKETS)) context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid, - `balanceId=${id(balance.id)}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) + `balanceId=${balanceId}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) if (!valid) return null + let balance = detailResponse.body?.data + if (missingZero) { + const prior = frozen[sourceKey] + const currentGroup = prior && groups.find(row => balanceDimension(row) === balanceDimension(prior.balance)) + const zeroProof = prior && positions.every(row => POSITION_BUCKETS.every(field => Number(row[field]) === 0)) + && ['inTransitPackages', 'procurementPackages'].every(field => Number(prior.balance[field]) === 0) + && BALANCE_BUCKETS.every(field => Number(currentGroup?.[field] || 0) + <= Number(prior.group?.[field] || 0) - Number(prior.balance[field])) + context.assert(MODULE, `${name}零余额排除由原ID位置及完整分组共同证实`, detailResponse, Boolean(zeroProof), + `balanceId=${balanceId}, retainedSource=${Boolean(prior)}, zeroPositions=${positions.length}`, OPTIONS) + if (!zeroProof) return null + balance = { ...prior.balance, ...Object.fromEntries(BALANCE_BUCKETS.map(field => [field, 0])) } + } else { + const owned = id(balance?.id) === balanceId && id(balance?.customerId) === customerId + && id(balance?.overseasWarehouseId) === warehouseId && validBuckets(balance, BALANCE_BUCKETS) + context.assert(MODULE, `${name}真实余额客户仓及完整数量有效`, detailResponse, owned, `balanceId=${balanceId}`, OPTIONS) + if (!owned) return null + const group = groups.find(row => balanceDimension(row) === balanceDimension(balance)) + const included = Boolean(group) && BALANCE_BUCKETS.every(field => Number(group[field]) >= Number(balance[field])) + context.assert(MODULE, `${name}真实非零余额包含于独立完整分组`, detailResponse, included, `balanceId=${balanceId}`, OPTIONS) + if (!included) return null + frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] } + } const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field])) context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled, - `balanceId=${id(balance.id)}, balanceTotal=${balance.totalPackages}`, OPTIONS) + `balanceId=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS) if (!reconciled) return null - // Balance DTO need not expose inboundCode. Text-filtered child tickets cannot count as this exact source. const exact = positions.filter(row => codes.has(row.inboundCode)) - if (!exact.length) continue - snapshot.push({ id: id(balance.id), customerId, overseasWarehouseId: warehouseId, + snapshot.push({ id: balanceId, customerId, overseasWarehouseId: warehouseId, inboundCodes: [...new Set(exact.map(row => row.inboundCode))].sort(), ...Object.fromEntries(POSITION_BUCKETS.map(field => [field, exact.reduce((sum, row) => sum + Number(row[field]), 0)])), - balance: inventoryQuantitySnapshot([balance])[0], - positions: inventoryQuantitySnapshot(positions), - matchingPositionIds: exact.map(row => id(row.id)).sort() }) + balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions), + matchingPositionIds: exact.map(row => id(row.id)).sort(), globalGroups: inventoryQuantitySnapshot(groups) }) } - return snapshot.sort((a, b) => a.id.localeCompare(b.id)) + const jointlyIncluded = groups.every(group => BALANCE_BUCKETS.every(field => snapshot + .filter(row => balanceDimension(row.balance) === balanceDimension(group)) + .reduce((sum, row) => sum + Number(row.balance[field]), 0) <= Number(group[field]))) + context.assert(MODULE, `${name}固定原始余额合计不超过独立分组`, null, jointlyIncluded, + `rawBalances=${snapshot.length}, groups=${groups.length}`, OPTIONS) + return jointlyIncluded ? snapshot : null +} + +export function dispatchInventorySources(task, dispatchNo, warehouseId) { + if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId + || !Array.isArray(task.items) || !task.items.length) return null + const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : []) + if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null + const references = new Map() + for (const row of positions) { + const previous = references.get(id(row.id)) + if (previous && previous !== id(row.balanceId)) return null + references.set(id(row.id), id(row.balanceId)) + } + return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(), + sourcePositionIds: [...references.keys()].sort(), + sourcePositionBalances: [...references].map(([positionId, balanceId]) => ({ positionId, balanceId })) + .sort((left, right) => left.positionId.localeCompare(right.positionId)) } +} + +export function inventorySourcesPresent(snapshot, references) { + return Array.isArray(snapshot) && references.every(reference => snapshot + .find(row => row.id === reference.balanceId)?.positions.some(position => position.id === reference.positionId + && position.balanceId === reference.balanceId)) } async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { @@ -166,6 +225,7 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp } const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) +const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0) const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) @@ -223,9 +283,14 @@ export async function completeReturnedException(context, { customerId, warehouse return null } const source = sources[0] - const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存') + const sourceBalanceIds = owned.inventoryProof?.sourceBalanceIds || [] + const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存', sourceBalanceIds) const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo) if (!stockBefore || !ledgerBefore) return null + if (!stockBefore.some(balance => balance.positions.some(position => position.id === id(source.inventoryPositionId)))) { + blocker(context, '退回原位置冻结归属', '原returnSource位置必须存在于正式排车冻结真实余额中') + return null + } const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}` const palletCode = continuing ? previousAttempt.targetPalletNo : `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) @@ -278,13 +343,13 @@ export async function completeReturnedException(context, { customerId, warehouse completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1 && /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS) if (!success(completed.response)) { - const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存') + const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存', sourceBalanceIds) const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo) context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response, sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS) return null } - const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存') + const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存', sourceBalanceIds) const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo) const targetPositions = [] for (const balance of stockAfter || []) { @@ -302,11 +367,12 @@ export async function completeReturnedException(context, { customerId, warehouse context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 && balanceQuantityTotal(stockAfter) - balanceQuantityTotal(stockBefore) === 1 + && groupQuantityTotal(stockAfter) - groupQuantityTotal(stockBefore) === 1 && newRows?.length === 1 && newRows[0].deltaPackages === 1 && newRows[0].afterPackages - newRows[0].beforePackages === 1, `before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS) const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody) - const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存') + const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存', sourceBalanceIds) const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo) context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response, replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId) @@ -551,6 +617,20 @@ function fixture(context) { return { dispatch, receiving } } +export async function verifyDepartureReadiness(context, token, dispatchNo) { + const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const passed = success(ready.response) && success(dispatch.response) + && ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED' + && ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo + && dispatch.data?.status === 'READY_TO_DEPART' + context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response, + passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS) + return passed ? ready : null +} + export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) @@ -726,31 +806,38 @@ export async function run(context) { await disablePicker(context, picker, 5) return } - const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存') - // Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks. - const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水') try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } - if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return - const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存') + const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId) + context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response, + success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS) + if (!sourceIds) return + const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds + context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } + const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds) + const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水') + const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) + context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) + if (!sourcesBeforeValid || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return + const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds) const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水') const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id)) const expectedPackages = Number(transferred.data.packageCount) context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, - stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages + inventorySourcesPresent(stockAfterPick, sourcePositionBalances) && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages && balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(stockAfterPick) === expectedPackages + && groupQuantityTotal(stockBeforePick) - groupQuantityTotal(stockAfterPick) === expectedPackages && expectedPackages > 0 && shippedRows?.length > 0 && -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages && shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages), `before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS) - const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response, - ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS) - if (!success(ready.response) || ready.data?.canDepart !== true) return + const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) + if (!ready) return const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) @@ -764,12 +851,13 @@ export async function run(context) { departReplay.data?.replayed === true && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) - const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存') + const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds) const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水') context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, - sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) + inventorySourcesPresent(stockAfterDepart, sourcePositionBalances) + && sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) context.createdResources.phase5OverseasFulfillment.inventoryProof = { - inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), + sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), afterPackages: quantityTotal(stockAfterDepart), expectedPackages, shipmentLedgerIds: (shippedRows || []).map(row => row.id), canDepart: ready.data?.canDepart === true, -- 2.54.0 From 39af04f169b184f6b01053218677867f881ce212 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 14:01:52 +0800 Subject: [PATCH 035/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase5-overseas-fulfillment.mjs | 111 ++++++++++++++---- 1 file changed, 91 insertions(+), 20 deletions(-) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index c5edd0bd8..5110e5a11 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -113,6 +113,13 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源') return null } + const inventoryEvidence = context.createdResources?.phase5OverseasFulfillment + if (inventoryEvidence) { + inventoryEvidence.inventoryReadEvidence ||= {} + inventoryEvidence.inventoryReadEvidence[name] = { customerId, warehouseId, inboundCodes: [...codes], + sourceBalanceIds: sourceIds, complete: false, globalGroups: null, sourceBalances: [] } + } + const readEvidence = inventoryEvidence?.inventoryReadEvidence?.[name] const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, { customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`) if (!groups) return null @@ -121,6 +128,7 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun && new Set(groups.map(balanceDimension)).size === groups.length context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS) if (!groupValid) return null + if (readEvidence) readEvidence.globalGroups = inventoryQuantitySnapshot(groups) context.context ||= {} context.context.phase5InventorySources ||= {} const frozen = context.context.phase5InventorySources @@ -167,6 +175,7 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun if (!included) return null frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] } } + if (readEvidence) readEvidence.sourceBalances.push({ balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions) }) const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field])) context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled, `balanceId=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS) @@ -183,6 +192,7 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun .reduce((sum, row) => sum + Number(row.balance[field]), 0) <= Number(group[field]))) context.assert(MODULE, `${name}固定原始余额合计不超过独立分组`, null, jointlyIncluded, `rawBalances=${snapshot.length}, groups=${groups.length}`, OPTIONS) + if (readEvidence) readEvidence.complete = jointlyIncluded return jointlyIncluded ? snapshot : null } @@ -218,10 +228,20 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp && (!businessOrderNo || row.businessOrderNo === businessOrderNo)) context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length, `rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS) - return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode, - palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages), - beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) + const quantities = rows.map(row => ({ id: id(row.id), customerId: id(row.customerId), + overseasWarehouseId: id(row.overseasWarehouseId), inboundOrderId: id(row.inboundOrderId), + inboundOrderNo: id(row.inboundOrderNo), inboundCode: id(row.inboundCode), palletNo: id(row.palletNo), + batchNo: id(row.batchNo), skuCode: id(row.skuCode), businessType: id(row.businessType), + businessOrderNo: id(row.businessOrderNo), outboundOrderNo: id(row.outboundOrderNo), + deltaPackages: Number(row.deltaPackages), beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) .sort((a, b) => a.id.localeCompare(b.id)) + const owned = context.createdResources?.phase5OverseasFulfillment + if (owned) { + owned.ledgerReadEvidence ||= {} + owned.ledgerReadEvidence[name] = { customerId, outboundNo, businessType, businessOrderNo: businessOrderNo || null, + complete: exact.length === rows.length, rows: quantities } + } + return exact.length === rows.length ? quantities : null } const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) @@ -229,6 +249,56 @@ const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0) const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) +export function dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, + sourcePositionBalances, stockBefore, stockAfter, ledgerBefore, ledgerAfter }) { + const positions = snapshot => (snapshot || []).flatMap(row => row.positions || []) + const referenceKeys = new Set(sourcePositionBalances.map(row => `${row.balanceId}:${row.positionId}`)) + const selected = snapshot => positions(snapshot).filter(row => referenceKeys.has(`${row.balanceId}:${row.id}`)) + const positionBefore = selected(stockBefore) + const positionAfter = selected(stockAfter) + const newLedger = ledgerAfter?.filter(row => !ledgerBefore?.some(before => before.id === row.id)) || [] + const rowChecks = newLedger.map(row => ({ id: row.id, + identity: row.customerId === customerId && row.overseasWarehouseId === warehouseId + && row.businessType === 'SHIP' && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo + && positionBefore.some(position => position.palletNo === row.palletNo), + quantities: [row.beforePackages, row.deltaPackages, row.afterPackages].every(Number.isSafeInteger) + && row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages })) + const identity = row => Object.fromEntries(INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, row[field]])) + const sourcesStable = positionBefore.every(row => { + const after = positionAfter.find(value => value.id === row.id && value.balanceId === row.balanceId) + return after && JSON.stringify(identity(row)) === JSON.stringify(identity(after)) + }) + const unselectedBefore = positions(stockBefore).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) + const unselectedAfter = positions(stockAfter).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) + const totals = { positionBefore: quantityTotal(positionBefore), positionAfter: quantityTotal(positionAfter), + rawBefore: balanceQuantityTotal(stockBefore), rawAfter: balanceQuantityTotal(stockAfter), + groupBefore: groupQuantityTotal(stockBefore), groupAfter: groupQuantityTotal(stockAfter), + ledgerShipped: -newLedger.reduce((sum, row) => sum + row.deltaPackages, 0), expectedPackages } + const checks = { + fixedSourcesPresent: inventorySourcesPresent(stockBefore, sourcePositionBalances) + && inventorySourcesPresent(stockAfter, sourcePositionBalances), + sourceIdentitiesStable: sourcesStable, + unselectedPositionsUnchanged: sameSnapshot(inventoryQuantitySnapshot(unselectedBefore), inventoryQuantitySnapshot(unselectedAfter)), + positionConservation: expectedPackages > 0 && totals.positionBefore - totals.positionAfter === expectedPackages, + rawConservation: stockBefore !== null && stockAfter !== null && totals.rawBefore - totals.rawAfter === expectedPackages, + groupConservation: stockBefore !== null && stockAfter !== null && totals.groupBefore - totals.groupAfter === expectedPackages, + ledgerSourceIdentities: ledgerBefore !== null && ledgerAfter !== null && newLedger.length > 0 + && new Set(newLedger.map(row => row.id)).size === newLedger.length && rowChecks.every(row => row.identity), + ledgerQuantities: newLedger.length > 0 && totals.ledgerShipped === expectedPackages && rowChecks.every(row => row.quantities), + } + const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks, + snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } } + if (context.createdResources?.phase5OverseasFulfillment) { + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof) + } + for (const [name, passed] of Object.entries(checks)) { + context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS) + } + const passed = Object.values(checks).every(Boolean) + context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, passed, JSON.stringify(totals), OPTIONS) + return { passed, proof, newLedger } +} + // A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) @@ -820,22 +890,20 @@ export async function run(context) { const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds) - const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水') + const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo) + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } }) const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) - if (!sourcesBeforeValid || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return + if (!sourcesBeforeValid || !ledgerBeforePick) return + const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo) const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds) - const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水') - const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id)) + const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo) const expectedPackages = Number(transferred.data.packageCount) - context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, - inventorySourcesPresent(stockAfterPick, sourcePositionBalances) && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages - && balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(stockAfterPick) === expectedPackages - && groupQuantityTotal(stockBeforePick) - groupQuantityTotal(stockAfterPick) === expectedPackages - && expectedPackages > 0 && shippedRows?.length > 0 - && -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages - && shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages), - `before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS) + const conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, + sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick, + ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick }) + const shippedRows = conservation.newLedger + if (!pickedSuccessfully) return const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) if (!ready) return const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } @@ -852,16 +920,19 @@ export async function run(context) { && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds) - const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水') + const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo) + const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof + inventoryProof.snapshots.afterDepart = stockAfterDepart + inventoryProof.ledgers.afterDepart = ledgerAfterDepart context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, inventorySourcesPresent(stockAfterDepart, sourcePositionBalances) && sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) - context.createdResources.phase5OverseasFulfillment.inventoryProof = { - sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), - afterPackages: quantityTotal(stockAfterDepart), expectedPackages, + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { + sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore, + afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages, shipmentLedgerIds: (shippedRows || []).map(row => row.id), canDepart: ready.data?.canDepart === true, - } + }) } finally { await disablePicker(context, picker, 5) if (picker) picker.token = null -- 2.54.0 From 0919a61661a0bde0c8e823a8661730215a587f65 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 14:27:45 +0800 Subject: [PATCH 036/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase3-loading-finalize.mjs | 2 +- .../phase3-order-splitting.mjs | 2 +- .../phase4-overseas-pda.mjs | 8 +- ...se5-overseas-fulfillment-contract.test.mjs | 76 +++- .../phase5-root-fulfillment.mjs | 342 ++++++++++++++++++ 5 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase5-root-fulfillment.mjs diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 2456d464c..c16d32705 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -107,7 +107,7 @@ async function requestPdf(context, path, body) { } } -async function verifyPalletLabels(context, fixture, loadingPlanId) { +export async function verifyPalletLabels(context, fixture, loadingPlanId) { if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null const warehouseId = id(fixture.warehouseId) const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels` diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 2e774186b..02ede02a6 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -176,7 +176,7 @@ function bookingPayload(marker, sequence, source, agent, carrier) { estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) } -async function loadOneFragment(context, state) { +export async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) const agentResponse = await context.request('GET', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 146c65592..35a3a9994 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -278,7 +278,7 @@ export async function uploadPhotos(context, token) { return photos } -async function awaitProducerConsumption(context, producers) { +export async function awaitProducerConsumption(context, producers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] if (!expected.length) return true const consumed = new Set() @@ -308,7 +308,7 @@ async function awaitProducerConsumption(context, producers) { return expected.every((containerNo) => consumed.has(containerNo)) } -async function convertReceivingContainers(context, producers) { +export async function convertReceivingContainers(context, producers) { for (const producer of producers) { const containerNo = id(producer?.containerNo) const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {}) @@ -646,7 +646,7 @@ async function receiveDropship(context, token, producer, inbounds) { return accepted === expectedPending } -async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { +export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { if (runAuthorizationProbes && context.token) { const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {}) context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) @@ -970,7 +970,7 @@ export async function run(context) { } } -async function resolveSharedWarehouse(context) { +export async function resolveSharedWarehouse(context) { const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE' const response = await context.request('GET', path) const rows = records(response?.body?.data) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 6c61baf67..fa5f6287d 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,5 @@ import test from 'node:test' -import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, verifyDepartureReadiness, verifyPartialOrderCompletion, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -476,3 +476,77 @@ test('source snapshot never treats unknown or incompletely proven raw 404 as zer const context = contextFor(() => { throw new Error('Missing fixed IDs cannot issue HTTP reads') }) assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null) }) + + +function representativePalletConservationFixture() { + const quantities = [1, 3, 1, 3, 1] + const codes = ['IN-A', 'IN-A-OTHER', 'IN-B', 'IN-B-OTHER', 'IN-C'] + const references = quantities.map((_, index) => ({ balanceId: String(index + 1), positionId: String(index + 11) })) + const groups = packages => [{ id: 'GROUP', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED', + totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] + const snapshot = after => quantities.map((quantity, index) => { + const balanceId = String(index + 1) + const position = { id: String(index + 11), balanceId, customerId: '100', overseasWarehouseId: 'WH-1', + inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C', + totalPackages: after ? 0 : quantity, availablePackages: after ? 0 : quantity, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 } + const leftover = { ...position, id: '99', palletNo: 'UNSELECTED', totalPackages: 3, availablePackages: 3 } + const positions = index === 4 ? [position, leftover] : [position] + const exact = positions.filter(row => ['IN-A', 'IN-B', 'IN-C'].includes(row.inboundCode)) + const total = positions.reduce((sum, row) => sum + row.totalPackages, 0) + return { id: balanceId, totalPackages: exact.reduce((sum, row) => sum + row.totalPackages, 0), + balance: { id: balanceId, totalPackages: total }, positions, globalGroups: groups(after ? 3 : 12) } + }) + const ledger = quantities.map((quantity, index) => ({ id: String(index + 101), customerId: '100', + overseasWarehouseId: 'WH-1', businessType: 'SHIP', businessOrderNo: 'DS-1', outboundOrderNo: 'DS-1', + inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C', + beforePackages: quantity, deltaPackages: -quantity, afterPackages: 0 })) + return { customerId: '100', warehouseId: 'WH-1', dispatchNo: 'DS-1', expectedPackages: 9, + sourcePositionBalances: references, stockBefore: snapshot(false), stockAfter: snapshot(true), + ledgerBefore: [], ledgerAfter: ledger } +} + +test('G05 MIN inbound-code display does not filter real same-pallet sources: positions9→0/raw12→3/groups12→3/SHIP-9', () => { + const fixture = representativePalletConservationFixture() + const context = contextFor(() => { throw new Error('Offline arithmetic cannot issue HTTP') }) + context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} } + assert.equal(fixture.stockBefore.reduce((sum, row) => sum + row.totalPackages, 0), 6) + assert.equal(fixture.stockAfter.reduce((sum, row) => sum + row.totalPackages, 0), 3) + const result = dispatchStockConservation(context, fixture) + assert.equal(result.passed, true) + assert.deepEqual(result.proof.totals, { positionBefore: 9, positionAfter: 0, rawBefore: 12, rawAfter: 3, + groupBefore: 12, groupAfter: 3, ledgerShipped: 9, expectedPackages: 9 }) + assert.equal(result.proof.ledgerRowChecks.length, 5) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + const saved = context.createdResources.phase5OverseasFulfillment.inventoryProof + assert.deepEqual(saved.snapshots.beforePick, fixture.stockBefore) + assert.deepEqual(saved.ledgers.afterPick, fixture.ledgerAfter) + assert.ok(JSON.stringify(saved).includes('IN-A-OTHER')) + assert.doesNotMatch(JSON.stringify(saved), /password|accessToken|Bearer|base64/) +}) + +test('G05 failures still independently review and preserve raw, group, each SHIP quantity/source, and unselected positions', () => { + for (const mutate of [ + fixture => { fixture.stockAfter[0].positions[0].id = 'WRONG-POSITION' }, + fixture => { fixture.stockAfter[0].balance.totalPackages = 1 }, + fixture => { for (const row of fixture.stockAfter) row.globalGroups[0].totalPackages = 4 }, + fixture => { fixture.ledgerAfter[0].deltaPackages = -2 }, + fixture => { fixture.ledgerAfter[0].customerId = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].overseasWarehouseId = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].outboundOrderNo = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].palletNo = 'UNSELECTED' }, + fixture => { fixture.stockAfter[4].positions[1].availablePackages = 2 }, + ]) { + const fixture = representativePalletConservationFixture(); mutate(fixture) + const context = contextFor(() => { throw new Error('Offline only') }) + context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} } + const result = dispatchStockConservation(context, fixture) + assert.equal(result.passed, false) + assert.equal(context.assertions.length, 9) + assert.equal(Object.keys(result.proof.checks).length, 8) + assert.equal(result.proof.ledgerRowChecks.length, 5) + assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.snapshots.afterPick, fixture.stockAfter) + assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter) + } +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs new file mode 100644 index 000000000..1b7848ab2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -0,0 +1,342 @@ +import { loadOneFragment } from './phase3-order-splitting.mjs' +import { verifyPalletLabels } from './phase3-loading-finalize.mjs' +import { stableLoadedCartonMarks } from './phase3-loading.mjs' +import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, + resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' +import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' + +const MODULE = 'phase5-root-fulfillment' +const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true } +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const PDA = '/api/pda/v1/last-leg/dispatches' +const id = value => value == null ? '' : String(value) +const success = response => response?.http === 200 && String(response?.body?.code) === '200' +const query = (path, values) => `${path}?${new URLSearchParams(values)}` + +export function fulfillmentHistorySnapshot(detail) { + return { + orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), + parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole, + status: detail?.order?.status, version: Number(detail?.order?.version), + inboundCode: detail?.order?.inboundCode, orderNo: detail?.order?.orderNo, + rawOrder: detail?.order, + histories: (detail?.statusHistory || []).map(row => ({ ...row, id: id(row.id) })), + } +} +export function validTwoFragmentTree(tree, rootOrderId, childOrderId) { + const rows = Array.isArray(tree?.children) ? tree.children : [] + const loaded = rows.filter(row => row.splitRole === 'LOADED') + const remainder = rows.filter(row => row.splitRole === 'REMAINDER') + return id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2 + && Number(tree.cumulativeLoadedPackageCount) === 2 && Number(tree.currentRemainderPackageCount) === 0 + && rows.length === 3 && loaded.length === 2 && remainder.length === 1 + && new Set(loaded.map(row => id(row.sourceOrderId))).size === 2 + && loaded.every(row => id(row.orderId) === id(row.sourceOrderId) && Number(row.packageCount) === 1) + && loaded.some(row => id(row.sourceOrderId) === id(rootOrderId)) + && loaded.some(row => id(row.sourceOrderId) === id(childOrderId)) + && id(remainder[0]?.orderId) === id(childOrderId) && id(remainder[0]?.sourceOrderId) === id(rootOrderId) + && Number(remainder[0]?.packageCount) === 1 +} +export function completionCounts(snapshot) { + return { + own: (snapshot?.histories || []).filter(row => row.action === 'OVERSEAS_DELIVERED').length, + final: (snapshot?.histories || []).filter(row => row.action === 'FULFILLMENT_COMPLETED').length, + } +} +export function validSignatureGate(snapshot, firstSource, allSigned = false) { + const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child) + if (allSigned) return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'COMPLETED' + && root.own === 1 && root.final === 1 && child.own === 1 && child.final === 0 + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED' + if (firstSource === 'CHILD') return snapshot?.root?.status !== 'COMPLETED' + && snapshot?.child?.status === 'COMPLETED' && root.own === 0 && root.final === 0 + && child.own === 1 && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' + return firstSource === 'MASTER' && snapshot?.root?.status !== 'COMPLETED' + && snapshot?.child?.status !== 'COMPLETED' && root.own === 1 && root.final === 0 && child.own === 0 + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' +} +export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, sourceOrderIds) { + const expected = [...new Set(sourceOrderIds.map(id))].sort() + const rows = Array.isArray(evidence?.orders) ? evidence.orders : [] + return evidence?.readOnly === true && id(evidence.customerId) === id(customerId) + && id(evidence.rootOrderId) === id(rootOrderId) + && rows.length === expected.length && JSON.stringify(rows.map(row => id(row.orderId)).sort()) === JSON.stringify(expected) + && rows.every(row => id(row.rootOrderId) === id(rootOrderId) && id(row.customerId) === id(customerId) + && row.eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' + && Number(row.count) === 1 && Number(row.publishedCount) === 1 && Number(row.pendingCount) === 0 + && Number(row.failedCount) === 0) +} +function check(context, label, condition, evidence, response = null) { + context.assert(MODULE, label, response, condition, evidence, OPTIONS) + if (!condition) throw new Error(`${MODULE}: ${label}: ${evidence}`) +} +async function api(context, method, label, path, body, token = context.token, headers = {}) { + const response = await context.request(method, path, body, token, headers) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + if (!success(response)) throw new Error(`${MODULE}: ${label}: ${response?.body?.code || response?.http}`) + return data +} +async function stage(context, name) { + const mod = await import(`./${name}.mjs`) + const before = context.steps?.length || 0 + await mod.run(context) + const failures = (context.steps || []).slice(before).filter(step => step.passed === false) + check(context, `F40 ${name} 正式前置没有失败`, failures.length === 0, `failed=${failures.length}`) +} +async function state(context, customerId, rootOrderId, childOrderId, rootInboundId) { + const root = fulfillmentHistorySnapshot(await api(context, 'GET', '读取根订单原始状态与完整历史', + query(`/api/platform/v1/orders/first-leg/${rootOrderId}`, { tenantId: customerId }))) + const child = childOrderId ? fulfillmentHistorySnapshot(await api(context, 'GET', '读取真实余量子订单原始状态与历史', + query(`/api/platform/v1/orders/first-leg/${childOrderId}`, { tenantId: customerId }))) : null + const inbound = rootInboundId ? await api(context, 'GET', '读取根来源 Firstleg 入仓摘要', + query(`/api/platform/inbounds/${rootInboundId}`, { tenantId: customerId })) : null + return { root, child, rootInbound: inbound?.summary || null } +} +async function waitState(read, predicate, attempts = 90) { + let last + for (let index = 0; index < attempts; index++) { + last = await read() + if (predicate(last)) return last + if (index + 1 < attempts) await new Promise(resolve => setTimeout(resolve, 1000)) + } + return last +} +async function domesticLogin(context) { + if (context.pdaToken) return + const username = context.options.pdaUsername, password = context.options.pdaPassword + check(context, 'F40 显式国内 PDA 账号已配置', Boolean(username && password), 'credentialsPresent=true/false') + const login = await api(context, 'POST', '登录正式国内 PDA 会话', '/api/auth/login', { loginId: username, password }, '') + context.pdaToken = login.accessToken + check(context, 'F40 国内 PDA 会话有效', Boolean(context.pdaToken), 'tokenPresent=true/false') +} +async function fragment(context, source, rootOrderId, sequence) { + const summary = source.summary + const result = await loadOneFragment(context, { + tenantId: id(summary.tenantId), rootOrderId, sourceOrderId: id(summary.orderId), + sourceOrderNo: summary.orderNo, customerName: summary.customerName, + channelCode: summary.channelCode, channelName: summary.channelName, + warehouseId: id(summary.warehouseId), warehouseName: summary.warehouseName, + sourcePackageCount: sequence === 0 ? 2 : 1, sequence, + }) + check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`) + const current = await api(context, 'GET', '回查该片真实入仓来源', + query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) })) + const container = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/${result.containerId}`) + const task = await api(context, 'GET', '回查该片真实完成 PDA 任务', + query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) })) + check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED' + && task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1, + `source=${current?.summary?.inboundCode}`) + const loading = { + marker: context.marker, tenantId: id(summary.tenantId), orderId: id(summary.orderId), + inboundId: result.sourceInboundId, inboundCode: current.summary.inboundCode, + oceanBookingId: result.oceanBookingId, containerBookingId: result.containerId, + loadingPlanId: result.planId, canonicalPdaTaskId: result.taskId, pickingTaskId: result.taskId, + warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo, + destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId), + destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items), + finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true, + } + context.createdResources.phase3Loading = loading + context.createdResources.orders = { ...(context.createdResources.orders || {}), ...loading, + taxMode: 'BUY_DECLARATION' } + const palletLabelManifest = await verifyPalletLabels(context, loading, result.planId) + check(context, 'F40 来源柜散货托盘码来自真实准备接口', Boolean(palletLabelManifest?.totalCount), `sequence=${sequence}`) + for (const name of ['phase3-customs', 'service-center-flow', 'phase3-port-schedules', + 'phase3-outbound-dispatch', 'phase3-container-documents', 'phase3-carrier-schedule-queries']) await stage(context, name) + const nodes = await import('./phase4-carrier-nodes.mjs') + await nodes.runUntilPickupReady(context) + const pickup = context.createdResources.phase4CarrierPickupReady + check(context, 'F40 来源柜实际节点达到 PICKUP_READY', pickup?.ready === true, `queryId=${pickup?.queryId || 'missing'}`) + return { ...loading, sourceOrderId: loading.orderId, sourceInboundId: loading.inboundId, + sourceStatus: 'LOADED', resourceKey: `${context.marker}:FBA:${loading.containerNo}`, + shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, + palletLabelManifest, rootOrderId } +} +async function sendFragment(context, producer, picker, merchantToken, index) { + const customerId = producer.tenantId + const candidates = await api(context, 'GET', '读取该来源入仓码实收排车候选', `${DISPATCH}/candidates?sourceStage=RECEIVED`) + const matches = (Array.isArray(candidates) ? candidates : []).filter(row => row.sourceStage === 'RECEIVED' + && row.inboundCode === producer.inboundCode && id(row.overseasWarehouseId) === id(picker.warehouseId)) + check(context, 'F40 只选择该根履约片的真实实收托盘', matches.length > 0 + && matches.every(row => Number(row.packageCount) > 0), `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) + const dispatch = await api(context, 'POST', '该履约片创建独立真实排车', `${DISPATCH}/pre-arrange`, { + planPalletIds: matches.map(row => id(row.planPalletId)), + }) + const dispatchNo = dispatch.dispatchNo + await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, { + vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', + driverPhone: '19900000000', carrierName: 'F40测试车队', + estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), + }) + const transfer = await api(context, 'POST', '客户正式创建该片派送单', + query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), + { idempotencyKey: `${context.marker}-F40-TRANSFER-${index}`, remark: context.marker }, merchantToken) + const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] + check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) + const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) + check(context, 'F40 派送实际物品来源和一箱数量严格匹配', detail.order?.customerId === customerId + && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) + && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) + context.marker = `${context.context.f40BaseMarker}-SEND-${index}` + check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`, + { idempotencyKey: `${context.marker}-DEPART`, remark: context.marker }, picker.token) + check(context, 'F40 正式发车已 DEPARTED', departed.status === 'DEPARTED', `dispatch=${dispatchNo}`) + const outbound = await api(context, 'GET', '回查实际来源出库完成', + query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' })) + check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED' + && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), + `outbound=${transfer.outboundNo}`) + const beforeDelivery = await api(context, 'GET', '读取本片派送推进前版本', deliveryPath) + const delivering = await api(context,'PUT','实际派送推进配送中', + query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), + {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) + check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) + const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) + check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) + return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, + receiptVersion: completed.order.version } +} + +/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ +export async function run(context) { + const proof = context.createdResources.phase5RootFulfillment = { + marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null, + fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null, + cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence', + } + const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST' + check(context,'F40 签收次序为显式正式来源顺序', ['ROOT_FIRST','CHILD_FIRST'].includes(signatureOrder), signatureOrder) + proof.signatureOrder = signatureOrder + const scope = Object.create(context) + scope.marker = `${context.marker}-F40` + scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', + orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' }, + waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, + allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` } + scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } } + await domesticLogin(scope) + await stage(scope, 'orders') + const orderFixture = { ...scope.createdResources.orders } + await stage(scope, 'inbound-measurement') + const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId) + proof.rootOrderId = rootOrderId; proof.customerId = customerId + const rootInbound = await api(scope, 'GET', '读取两箱根真实 WAIT_SPLIT 来源', + query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId })) + check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT' + && Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`) + const first = await fragment(scope, rootInbound, rootOrderId, 0) + proof.fragments.push(first) + const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树', + query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) + const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || [] + check(scope, 'F40 首柜仅生成一个真实一箱余量子单', remainder.length === 1 + && Number(remainder[0].packageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1, `children=${remainder.length}`) + const childOrderId = id(remainder[0].orderId) + proof.childOrderId = childOrderId + const childDetail = await api(scope, 'GET', '读取实际余量订单身份', + query(`/api/platform/v1/orders/first-leg/${childOrderId}`, { tenantId: customerId })) + check(scope, 'F40 子订单根父身份完整一致', childDetail.order?.orderRole === 'CHILD' + && id(childDetail.order.rootOrderId) === rootOrderId && id(childDetail.order.parentOrderId) === rootOrderId, + `root=${rootOrderId}, child=${childOrderId}`) + const childCandidates = await api(scope, 'GET', '从公开入仓列表定位真实子入仓单', + query('/api/platform/inbounds', { tenantId: customerId, inboundCode: childDetail.order.inboundCode, page:'1',size:'20' })) + const childRows = (childCandidates.records || []).filter(row => id(row.orderId) === childOrderId + && row.inboundCode === childDetail.order.inboundCode) + check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`) + const childInbound = await api(scope, 'GET', '读取子单可装柜来源', + query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) + scope.marker = `${scope.context.f40BaseMarker}-CHILD` + const second = await fragment(scope, childInbound, rootOrderId, 1) + proof.fragments.push(second) + const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树', + query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) + check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId) + && first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`) + const before = await state(scope, customerId, rootOrderId, childOrderId, first.inboundId) + check(scope, 'F40 尚未签收两片不得出现完成事实', before.root.role === 'MASTER' + && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' + && before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures') + proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before }) + const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope) + check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent') + scope.marker = `${scope.context.f40BaseMarker}-PDA` + const picker = await createPicker(scope, 7, warehouse.id, access) + check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') + picker.warehouseId = warehouse.id + try { + const loginId = scope.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME + const password = scope.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD + const login = await api(scope, 'POST', 'F40 客户正式登录', '/api/auth/login', { loginId, password }, '') + const profile = await api(scope, 'GET', 'F40 客户会话归属核验', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken) + check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') + scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] } + scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, + palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], + overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null } + check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed') + check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted') + const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second] + for (const [index, producer] of signatureSources.entries()) { + scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}` + scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker + scope.createdResources.phase4OverseasPda.marker = scope.marker + scope.createdResources.phase4OverseasPda.finalStatus = null + scope.pdaContainerSuffix = producer.containerNo + check(scope, 'F40 码头正式提柜前置完成', await prepareTerminalDrayage(scope, producer), `container=${producer.containerNo}`) + await runReceiving(scope,picker.token,null,null,producer,false) + check(scope, 'F40 来源柜 PDA 卸柜实收完成', scope.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED' + && scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row => + id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo), + `container=${producer.containerNo}`) + const signed = await sendFragment(scope,producer,picker,login.accessToken,index) + proof.signatures.push(signed) + const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId), + value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1)) + proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed }) + check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次', + validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder) + } + const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + // Existing receipt helper replays the exact original save command before each signature observation. + // Re-saving completed receipts with a fresh business key is also a no-transition operation. + for (const [index,signed] of proof.signatures.entries()) { + scope.marker = `${scope.context.f40BaseMarker}-REPLAY-${index}` + const current = await api(scope,'GET','读取已完成签收文件绑定版本', + query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId})) + const documents = (current.categories || []).filter(row => row.files?.length).map(row => ({category:row.category, + fileIds:row.files.map(file => id(file.fileId))})) + await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', + query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}), + {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}) + } + const afterReplay = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增', + JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot') + proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay}) + const reader = context.context?.f40ReadOnlyOutboxEvidence + if (typeof reader === 'function') { + proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) + check(scope,'F40 每个实际来源完成Outbox唯一并已发布', + exactCompletionOutboxProof(proof.outboxProof,customerId,rootOrderId,[rootOrderId,childOrderId]),'exact-root/source/customer/readOnly-proof') + proof.finalStatus='VERIFIED' + } else { + proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING' + context.addCoverageGap(MODULE,OPTIONS.scenario,'F40 完成Outbox唯一发布只读证据', + '原始Order状态与历史已验证;仍需对本轮客户/根/子 exact source 完成Outbox做只读计数', + 'missing-outbox-evidence',{deploymentBlocker:true}) + context.addStep(MODULE,'F40 真实完成Outbox唯一发布证据',null,false,'readonly evidence reader missing',OPTIONS) + } + } catch (error) { + proof.finalStatus = 'FAILED' + throw error + } finally { await disablePicker(scope,picker,7) } + return proof +} -- 2.54.0 From 22584e7fdece2868f03191f95a740bb88d112872 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 15:16:39 +0800 Subject: [PATCH 037/176] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-acceptance.mjs | 6 +- scripts/admin-test-env-modules/README.md | 2 + ...und-measurement-box-code-contract.test.mjs | 131 +++++++++++- .../inbound-measurement.mjs | 121 ++++++++++- ...ers-approval-convergence-contract.test.mjs | 75 ++++++- scripts/admin-test-env-modules/orders.mjs | 69 ++++++ .../phase4-overseas-pda-contract.test.mjs | 31 ++- .../phase4-overseas-pda.mjs | 32 ++- ...se5-overseas-fulfillment-contract.test.mjs | 111 +++++++++- .../phase5-overseas-fulfillment.mjs | 109 ++++++++-- .../phase5-root-fulfillment-contract.test.mjs | 202 ++++++++++++++++++ .../phase5-root-fulfillment.mjs | 111 ++++++++-- scripts/database-schema-diff.mjs | 15 +- scripts/database-schema-diff.test.mjs | 15 +- ...verseas-acceptance-entry-contract.test.mjs | 9 + 15 files changed, 994 insertions(+), 45 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 252af883c..dcf0b8e87 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url' import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] -const OPTIONAL_MODULES = ['phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', +const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', @@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['phase5-root-fulfillment', ['merchant-qualification-fixture']], ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']], @@ -343,6 +344,7 @@ export function parseArguments(argv) { const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' const requiresMarkerOwnedMerchantFixture = [ + 'phase5-root-fulfillment', 'phase4-overseas-business', 'phase4-overseas-return-receipt', 'phase4-overseas-client-services', @@ -399,7 +401,7 @@ export function validateExecutionOptions(options) { } if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported') if ('overseasGateway' in options) throw new Error('Use the single gateway option') - if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module)) + if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module)) && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)') } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index cc8c75213..a68a7ae97 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数;缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。 + 单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。 普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 8ed2a6bf6..14c7fbcb1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { completeDropshipPalletization } from './inbound-measurement.mjs' +import { completeDropshipPalletization, verifyMeasurementSubmitReplay, + verifyMeasurementApprovalReplay } from './inbound-measurement.mjs' const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8') const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') @@ -191,3 +192,131 @@ test('pregenerated pallet regression detects duplicate replay and orphan empty p const emptyChecks = await exerciseSystemPalletScenario('empty-pallet') assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed)) }) + +function measurementReplayContext(approved = false, mutate = () => {}, submitCode = 'A0409') { + const input = { inboundId: '9007199254740993101', tenantId: '9007199254740993102', + measurementId: '9007199254740993103' } + const state = { + measurement: { inboundId: input.inboundId, measurementId: input.measurementId, inboundCode: 'REPLAY-IN', + measurementStatus: approved ? 'APPROVED' : 'SUBMITTED', measureReviewStatus: approved ? 'APPROVED' : 'PENDING', + processStatus: approved ? 'WAIT_SPLIT' : 'MEASURE_PENDING', version: 3, inboundVersion: 4, + actualPackageCount: 4, actualGrossWeightKg: 100, actualVolumeCbm: 1, + items: [{ id: '9007199254740993104', packageCount: 4, boxCode: 'REPLAY-IN#4-1' }], + weightResults: [{ id: '9007199254740993105', fileId: '9007199254740993115', grossWeightKg: 100 }], + palletLocationGuide: { blockId: '9007199254740993106', slots: [{ reservationId: '9007199254740993107' }] } }, + inbound: { summary: { id: input.inboundId, tenantId: input.tenantId, version: 4 }, + statusHistory: [{ id: '9007199254740993108', action: approved ? 'MEASURE_APPROVE' : 'MEASURE_SUBMIT' }] }, + reviews: [{ id: '9007199254740993109', measurementId: input.measurementId, reviewResult: 'APPROVED' }], + fee: { feeId: '9007199254740993110', feeVersion: 1, inboundId: input.inboundId, + measurementId: input.measurementId, feeStatus: 'CALCULATED', totalAmount: 77 }, + details: [{ id: '9007199254740993111', sourceFeeId: '9007199254740993110', sourceFeeVersion: 1, + inboundId: input.inboundId, amount: 77 }], + } + const calls = [] + const context = { marker: 'MEASUREMENT-REPLAY', token: 'memory-only-test-token', + request: async (method, requestPath, body, token, headers) => { + calls.push({ method, requestPath, body: structuredClone(body), token, headers }) + if (method === 'POST') { + mutate(state) + if (requestPath.includes('/measurement/submit?')) return { + http: 200, body: { code: submitCode }, requestId: 'submit-repeat', traceId: 'trace-submit', + } + assert.match(requestPath, /\/measurements\/approve\?/) + return { http: 200, body: { code: '200', data: { + initialPricingSucceeded: true, initialFee: structuredClone(state.fee), + } }, requestId: 'approve-repeat', traceId: 'trace-approve' } + } + let data + if (requestPath.includes('/measurement?')) data = state.measurement + else if (requestPath.startsWith(`/api/platform/inbounds/${input.inboundId}?`)) data = state.inbound + else if (requestPath.includes('/measurement-review-history?')) data = { + records: state.reviews, total: state.reviews.length, page: 1, size: 200, + } + else if (requestPath.includes('/fees/latest?')) data = state.fee + else if (requestPath.includes('/fee-details?')) { + assert.equal(new URL(requestPath, 'https://example.invalid').searchParams.get('includeHistory'), 'true') + data = state.details + } else throw new Error(`unexpected ${method} ${requestPath}`) + return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'read', traceId: 'trace' } + }, + apiSuccess: (_module, name, response) => { + assert.equal(response.http, 200, name); assert.equal(response.body.code, '200', name); return response.body.data + }, + assert: (_module, name, _response, passed) => assert.ok(passed, name), + } + return { input, state, context, calls } +} + +test('submitted measurement repeats the original payload, requires conflict and preserves all readable facts', async () => { + const fixture = measurementReplayContext() + const payload = { measurementId: fixture.input.measurementId, version: 2 } + const evidence = await verifyMeasurementSubmitReplay(fixture.context, fixture.input, payload) + assert.equal(evidence.rejected, true); assert.equal(evidence.unchanged, true) + assert.equal(evidence.beforeDigest, evidence.afterDigest) + assert.equal(evidence.historyCount, 1) + assert.deepEqual(fixture.calls.find(call => call.method === 'POST').body, payload) + assert.equal(fixture.calls.find(call => call.method === 'POST').headers, undefined) + assert.equal(Object.hasOwn(evidence, 'weightResults'), false) +}) + +for (const [name, mutate] of [ + ['new batch', state => { state.measurement.measurementId = '9007199254740993199' }], + ['batch version', state => { state.measurement.version += 1 }], + ['extra item', state => state.measurement.items.push({ id: '9007199254740993198', packageCount: 1 })], + ['changed weight', state => { state.measurement.weightResults[0].grossWeightKg += 1 }], + ['extra inbound history', state => state.inbound.statusHistory.push({ id: '9007199254740993197' })], +]) { + test(`duplicate measurement submission detects ${name}`, async () => { + const fixture = measurementReplayContext(false, mutate) + await assert.rejects(verifyMeasurementSubmitReplay(fixture.context, fixture.input, + { measurementId: fixture.input.measurementId, version: 2 }), /零漂移|原入库原批次/) + }) +} + +test('duplicate measurement submission must not accept a successful mutation response', async () => { + const fixture = measurementReplayContext(false, () => {}, '200') + await assert.rejects(verifyMeasurementSubmitReplay(fixture.context, fixture.input, + { measurementId: fixture.input.measurementId, version: 2 }), /再次提交明确A0409/) +}) + +test('approval replay uses the original key and old expectedVersion and compares all fee versions and histories', async () => { + const fixture = measurementReplayContext(true) + const payload = { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null } + const evidence = await verifyMeasurementApprovalReplay(fixture.context, fixture.input, payload, 'original-review-key') + const post = fixture.calls.find(call => call.method === 'POST') + assert.deepEqual(post.body, payload) + assert.equal(post.headers['Idempotency-Key'], 'original-review-key') + assert.equal(evidence.unchanged, true) + assert.equal(evidence.beforeDigest, evidence.afterDigest) + assert.equal(evidence.feeId, fixture.state.fee.feeId) + assert.equal(evidence.feeVersion, 1) + assert.equal(evidence.reviewIds.length, 1) + assert.equal(evidence.feeDetailIds.length, 1) + assert.equal(JSON.stringify(evidence).includes(fixture.context.token), false) +}) + +for (const [name, mutate] of [ + ['different fee ID', state => { state.fee.feeId = '9007199254740993191' }], + ['extra fee version', state => { state.fee.feeVersion += 1 }], + ['extra fee detail', state => state.details.push({ ...state.details[0], id: '9007199254740993192' })], + ['changed historical fee detail', state => { state.details[0].amount += 1 }], + ['duplicate review record', state => state.reviews.push({ ...state.reviews[0], id: '9007199254740993193' })], + ['extra inbound history', state => state.inbound.statusHistory.push({ id: '9007199254740993194' })], +]) { + test(`approval replay detects ${name} despite successful response`, async () => { + const fixture = measurementReplayContext(true, mutate) + await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input, + { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'), + /零新增|唯一审核及已生成初算费用事实有效/) + }) +} + +test('review history uses the real reviewResult field and rejects a fabricated result alias', async () => { + const fixture = measurementReplayContext(true) + fixture.state.reviews[0] = { id: '9007199254740993109', measurementId: fixture.input.measurementId, result: 'APPROVED' } + await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input, + { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'), + /唯一审核及已生成初算费用事实有效/) + assert.equal(fixture.calls.some(call => call.method === 'POST'), false) + assert.match(source, /item\.reviewResult === 'APPROVED'/) +}) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index a5f849664..319527697 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -28,6 +30,110 @@ function validPdf(response) { && response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF' } +export function measurementReplayDigest(facts) { + return createHash('sha256').update(JSON.stringify(facts)).digest('hex') +} + +async function readMeasurementReplayFacts(context, { inboundId, tenantId, measurementId }, name) { + const response = await context.request('GET', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) + const measurement = context.apiSuccess(MODULE, `${name}测量批次明细及重量事实`, response, '', OPTIONS) + const inboundResponse = await context.request('GET', `/api/platform/inbounds/${inboundId}?${query(tenantId)}`) + const inbound = context.apiSuccess(MODULE, `${name}入库状态及历史`, inboundResponse, '', OPTIONS) + const valid = asId(measurement?.inboundId) === inboundId && asId(measurement?.measurementId) === measurementId + && Array.isArray(measurement?.items) && measurement.items.length > 0 + && Array.isArray(measurement?.weightResults) && measurement.weightResults.length > 0 + && new Set(measurement.items.map(item => asId(item.id))).size === measurement.items.length + && new Set(measurement.weightResults.map(item => asId(item.id))).size === measurement.weightResults.length + && asId(inbound?.summary?.id) === inboundId && asId(inbound?.summary?.tenantId) === tenantId + && Array.isArray(inbound?.statusHistory) + context.assert(MODULE, `${name}原入库原批次明细及历史可核对`, response, valid, + `measurementId=${asId(measurement?.measurementId)}, items=${measurement?.items?.length ?? 'missing'}` + + `, weights=${measurement?.weightResults?.length ?? 'missing'}`, OPTIONS) + if (!valid) throw new Error('测量重放核验缺少原批次或完整明细历史事实') + return { response, facts: { measurement, inbound } } +} + +export async function verifyMeasurementSubmitReplay(context, input, payload) { + const before = await readMeasurementReplayFacts(context, input, '重复提交前') + const response = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${input.inboundId}/measurement/submit?${query(input.tenantId)}`, payload) + const rejected = response?.http === 200 && String(response.body?.code) === 'A0409' + context.assert(MODULE, '已提交测量再次提交明确A0409', response, rejected, + `HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, OPTIONS) + const after = await readMeasurementReplayFacts(context, input, '重复提交后') + const beforeDigest = measurementReplayDigest(before.facts) + const afterDigest = measurementReplayDigest(after.facts) + const unchanged = beforeDigest === afterDigest + context.assert(MODULE, '重复测量提交拒绝后批次版本明细重量及入库历史零漂移', after.response, + unchanged, `items=${after.facts.measurement.items.length}, weights=${after.facts.measurement.weightResults.length}` + + `, histories=${after.facts.inbound.statusHistory.length}`, OPTIONS) + return { measurementId: input.measurementId, rejected, unchanged, beforeDigest, afterDigest, + itemIds: after.facts.measurement.items.map(item => asId(item.id)), + weightIds: after.facts.measurement.weightResults.map(item => asId(item.id)), + measurementVersion: after.facts.measurement.version, + inboundVersion: after.facts.measurement.inboundVersion, historyCount: after.facts.inbound.statusHistory.length } +} + +export async function verifyMeasurementApprovalReplay(context, input, payload, idempotencyKey) { + async function read(name) { + const snapshot = await readMeasurementReplayFacts(context, input, name) + const reviews = []; let total = null; let page = 1 + while (total == null || reviews.length < total) { + const response = await context.request('GET', + `/api/platform/v1/inbounds/${input.inboundId}/measurement-review-history?${query(input.tenantId)}&page=${page}&size=200`) + const data = context.apiSuccess(MODULE, `${name}审核历史第${page}页`, response, '', OPTIONS) + const valid = Array.isArray(data?.records) && Number.isInteger(Number(data?.total)) && Number(data.total) >= 0 + && (total == null || total === Number(data.total)) + && (data.records.length > 0 || Number(data.total) === 0) + context.assert(MODULE, `${name}审核历史完整分页`, response, valid, + `page=${page}, total=${data?.total ?? 'missing'}`, OPTIONS) + if (!valid) throw new Error('测量审核重放缺少完整审核历史') + total = Number(data.total); reviews.push(...data.records); page += 1 + } + const feeResponse = await context.request('GET', + `/api/platform/v1/inbounds/${input.inboundId}/fees/latest?${query(input.tenantId)}`) + const fee = context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS) + const detailsResponse = await context.request('GET', + `/api/platform/v1/inbounds/${input.inboundId}/fee-details?accountId=${encodeURIComponent(input.tenantId)}&includeHistory=true`) + const feeDetails = context.apiSuccess(MODULE, `${name}全部版本费用明细`, detailsResponse, '', OPTIONS) + const approvedReviewCount = reviews.filter(item => asId(item.measurementId) === input.measurementId + && item.reviewResult === 'APPROVED').length + const valid = snapshot.facts.measurement.measurementStatus === 'APPROVED' + && snapshot.facts.measurement.measureReviewStatus === 'APPROVED' + && reviews.length === total && new Set(reviews.map(item => asId(item.id))).size === total + && approvedReviewCount === 1 + && Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1 + && Array.isArray(feeDetails) && new Set(feeDetails.map(item => asId(item.id))).size === feeDetails.length + context.assert(MODULE, `${name}唯一审核及已生成初算费用事实有效`, detailsResponse, valid, + `reviews=${reviews.length}, feeId=${asId(fee?.feeId)}, feeVersion=${fee?.feeVersion ?? 'missing'}` + + `, feeDetails=${feeDetails?.length ?? 'missing'}, approvedReviewCount=${approvedReviewCount}` + + `, measurementStatus=${snapshot.facts.measurement.measurementStatus}` + + `, measureReviewStatus=${snapshot.facts.measurement.measureReviewStatus}`, OPTIONS) + if (!valid) throw new Error('测量审核重放缺少唯一审核或已生成初算费用事实') + return { response: snapshot.response, facts: { ...snapshot.facts, reviews, fee, feeDetails } } + } + const before = await read('审核同键重放前') + const response = await context.request('POST', + `/api/platform/v1/inbounds/${input.inboundId}/measurements/approve?${query(input.tenantId)}`, + payload, context.token, { 'Idempotency-Key': idempotencyKey }) + const replay = context.apiSuccess(MODULE, '测量审核同原键原请求重放成功', response, '', OPTIONS) + const after = await read('审核同键重放后') + const beforeDigest = measurementReplayDigest(before.facts) + const afterDigest = measurementReplayDigest(after.facts) + const unchanged = beforeDigest === afterDigest && replay?.initialPricingSucceeded === true + && asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId) + && Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion) + context.assert(MODULE, '测量审核重放保持批次费用ID版本全部明细审核及入库历史零新增', after.response, + unchanged, `feeId=${asId(after.facts.fee.feeId)}, version=${after.facts.fee.feeVersion}` + + `, reviews=${after.facts.reviews.length}, details=${after.facts.feeDetails.length}`, OPTIONS) + return { measurementId: input.measurementId, unchanged, beforeDigest, afterDigest, + feeId: asId(after.facts.fee.feeId), feeVersion: after.facts.fee.feeVersion, + feeDetailIds: after.facts.feeDetails.map(item => asId(item.id)), + reviewIds: after.facts.reviews.map(item => asId(item.id)), + inboundHistoryCount: after.facts.inbound.statusHistory.length } +} + async function reusablePallets(context, warehouseId) { const cacheKey = asId(warehouseId) const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true @@ -1055,14 +1161,17 @@ export async function run(context) { } } + const submissionPayload = { measurementId: draft.measurementId, version: Number(draft.version) } + const replayInput = { inboundId, tenantId, measurementId: asId(draft.measurementId) } const submitResponse = await context.request('POST', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`, - { measurementId: draft.measurementId, version: Number(draft.version) }) + submissionPayload) const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse, `measurementId=${asId(draft.measurementId)}`, OPTIONS) context.assert(MODULE, '提交后进入待测量审核状态', submitResponse, submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING', `processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS) + const submissionReplay = await verifyMeasurementSubmitReplay(context, replayInput, submissionPayload) const rejectedScanResponse = await multipartRequest(context, scanPath, { boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19), @@ -1081,9 +1190,12 @@ export async function run(context) { `status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS) const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}` - const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, { + const approvalPayload = { measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null, - }, { 'Idempotency-Key': `${context.marker}-mojia-approve`.slice(0, 128) }) + } + const approvalKey = `${context.marker}-mojia-approve`.slice(0, 128) + const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, approvalPayload, + { 'Idempotency-Key': approvalKey }) context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse, `measurementId=${asId(draft.measurementId)}`, OPTIONS) @@ -1107,6 +1219,8 @@ export async function run(context) { && closeTo(finalMeasurement?.volumeWeightKg, volumeWeight), `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) + const approvalReplay = await verifyMeasurementApprovalReplay(context, replayInput, approvalPayload, approvalKey) + let sealedCartonMarks = [] let sealedSourcePalletNos = [] if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { @@ -1156,6 +1270,7 @@ export async function run(context) { OPTIONS) context.createdResources.inboundMeasurement = { + replayEvidence: { submission: submissionReplay, approval: approvalReplay }, sealedCartonMarks, sealedSourcePalletNos, marker: context.marker, tenantId, inboundId, orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId), diff --git a/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs b/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs index 400228596..4a3d3e16c 100644 --- a/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { approvedInboundReady } from './orders.mjs' +import { approvedInboundReady, verifyOrderApprovalReplay } from './orders.mjs' const tenantId = '10001' const orderId = '20001' @@ -48,3 +48,76 @@ test('orders acceptance polls approval convergence instead of only inbound id vi assert.doesNotMatch(source, /MEASURABLE_STATUSES\.has\(summary\?\.processStatus\)/) assert.doesNotMatch(source, /if \(success\(response\) && response\.body\?\.data\?\.summary\?\.inboundId\)/) }) + +function approvalReplayContext(mutate = () => {}, approvalCode = 'A0409') { + const inboundId = '40001'; const inboundCode = 'AT-APPROVAL' + const state = { + order: { order: { id: orderId, status: 'APPROVED', inboundCode, version: 2 }, + cargoes: [], parties: [], internalNotes: [], statusHistory: [{ id: 'history-order', action: 'APPROVED' }] }, + task: { id: '30001', tenantId, orderId, status: 'WAIT_MEASURE' }, + inbound: { summary: { id: inboundId, tenantId, inboundCode, version: 2, processStatus: 'WAIT_MEASURE' }, + statusHistory: [{ id: 'history-inbound', action: 'ORDER_APPROVED' }], files: [], palletLocations: [] }, + rows: [{ id: inboundId, tenantId, orderId, inboundCode }], + } + const calls = []; const checks = [] + const context = { marker: 'APPROVAL-REPLAY', createdResources: { orders: {} }, + request: async (method, requestPath, body, token, headers) => { + calls.push({ method, requestPath, body, token, headers }) + if (method === 'POST') { + assert.match(requestPath, /\/orders\/first-leg\/20001\/approve\?tenantId=10001$/) + assert.equal(body, undefined); assert.equal(headers, undefined) + mutate(state) + return { http: 200, body: { code: approvalCode }, requestId: 'repeat-approve', traceId: 'trace-approve' } + } + let data + if (requestPath.includes('/orders/first-leg/')) data = state.order + else if (requestPath.includes('/tasks/by-order/')) data = state.task + else if (requestPath.startsWith(`/api/platform/inbounds/${inboundId}?`)) data = state.inbound + else if (requestPath.startsWith('/api/platform/inbounds?')) data = { + records: state.rows, page: 1, size: 100, total: state.rows.length, pages: 1, + } + else throw new Error(`unexpected ${method} ${requestPath}`) + return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'read', traceId: 'trace' } + }, + apiSuccess: (_module, _name, response) => { + assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data + }, + assert: (_module, name, _response, passed) => { + checks.push({ name, passed }); assert.ok(passed, name) + }, + } + return { context, calls, checks, input: { tenantId, orderId, inboundId, inboundCode } } +} + +test('repeated approval requires A0409 and compares order, task, inbound identity, histories and unique count', async () => { + const fixture = approvalReplayContext() + const result = await verifyOrderApprovalReplay(fixture.context, fixture.input) + assert.equal(result.rejected, true) + assert.equal(result.unchanged, true) + assert.equal(result.inboundCount, 1) + assert.equal(result.orderHistoryCount, 1) + assert.equal(result.inboundHistoryCount, 1) + assert.equal(result.beforeDigest, result.afterDigest) + assert.equal(fixture.calls.filter(call => call.method === 'POST').length, 1) + assert.equal(fixture.context.createdResources.orders.approvalReplay, result) + assert.equal(Object.hasOwn(result, 'order'), false) + assert.match(result.beforeDigest, /^[a-f0-9]{64}$/) +}) + +for (const [name, mutate] of [ + ['extra approval history', state => state.order.statusHistory.push({ id: 'repeat-history', action: 'APPROVED' })], + ['changed inbound version', state => state.inbound.summary.version += 1], + ['extra inbound history', state => state.inbound.statusHistory.push({ id: 'repeat-inbound-history' })], + ['new fulfillment task', state => { state.task.id = '30002' }], + ['duplicate source inbound', state => state.rows.push({ ...state.rows[0], id: '40002' })], +]) { + test(`approval replay detects ${name} instead of accepting unchanged response status`, async () => { + const fixture = approvalReplayContext(mutate) + await assert.rejects(verifyOrderApprovalReplay(fixture.context, fixture.input), /零漂移|唯一入库事实有效/) + }) +} + +test('successful repeated approval is not accepted as a substitute for the formal conflict contract', async () => { + const fixture = approvalReplayContext(() => {}, '200') + await assert.rejects(verifyOrderApprovalReplay(fixture.context, fixture.input), /再次审核明确A0409/) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index dd2f7bc54..a48979c7e 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -86,6 +86,72 @@ export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderI && id(task?.orderId) === id(orderId) && id(task?.tenantId) === id(tenantId) } + +export function approvalReplayDigest(snapshot) { + return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex') +} + +export async function verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, inboundCode }) { + const scope = `tenantId=${encodeURIComponent(tenantId)}` + const orderPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?${scope}` + const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?${scope}` + const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?${scope}` + async function read(name) { + const orderResponse = await context.request('GET', orderPath) + const detail = context.apiSuccess(MODULE, `${name}订单及状态历史`, orderResponse, '', CONTRACT_OPTIONS) + const taskResponse = await context.request('GET', taskPath) + const task = context.apiSuccess(MODULE, `${name}原履约任务`, taskResponse, '', CONTRACT_OPTIONS) + const inboundResponse = await context.request('GET', inboundPath) + const inbound = context.apiSuccess(MODULE, `${name}原入库单及历史`, inboundResponse, '', CONTRACT_OPTIONS) + const rows = []; let total = null; let page = 1 + while (total == null || rows.length < total) { + const response = await context.request('GET', `/api/platform/inbounds?${scope}` + + `&inboundCode=${encodeURIComponent(inboundCode)}&page=${page}&size=100`) + const data = context.apiSuccess(MODULE, `${name}入仓码来源入库列表第${page}页`, response, '', CONTRACT_OPTIONS) + const valid = pageOk(data) && Number.isInteger(Number(data.total)) && Number(data.total) >= 0 + && (total == null || total === Number(data.total)) + && (data.records.length > 0 || Number(data.total) === 0) + context.assert(MODULE, `${name}入库分页计数完整`, response, valid, + `page=${page}, total=${data?.total ?? 'missing'}`, CONTRACT_OPTIONS) + if (!valid) throw new Error('审核重放核验缺少完整入库分页事实') + total = Number(data.total); rows.push(...data.records); page += 1 + } + const exact = rows.filter(row => id(row.inboundCode) === inboundCode) + const valid = detail?.order?.status === 'APPROVED' && id(detail.order.id) === orderId + && id(detail.order.inboundCode) === inboundCode && Array.isArray(detail.statusHistory) + && id(task?.orderId) === orderId && id(task?.tenantId) === tenantId && Boolean(id(task?.id)) + && id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId + && id(inbound?.summary?.inboundCode) === inboundCode && Array.isArray(inbound?.statusHistory) + && rows.length === total && new Set(rows.map(row => id(row.id))).size === total + && exact.length === 1 && id(exact[0].id) === inboundId + context.assert(MODULE, `${name}审核态原身份与唯一入库事实有效`, inboundResponse, valid, + `exactInbounds=${exact.length}, taskId=${id(task?.id)}, inboundId=${id(inbound?.summary?.id)}`, CONTRACT_OPTIONS) + if (!valid) throw new Error('审核重放核验的订单任务或唯一入库身份不一致') + return { response: inboundResponse, facts: { + order: JSON.parse(orderPersistenceFacts(detail)), task, inbound, exactInbounds: exact, + } } + } + const before = await read('审核重放前') + const response = await context.request('POST', + `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?${scope}`) + const rejected = response?.http === 200 && String(response.body?.code) === 'A0409' + context.assert(MODULE, '已审核订单再次审核明确A0409', response, rejected, + `HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) + const after = await read('审核重放后') + const beforeDigest = approvalReplayDigest(before.facts) + const afterDigest = approvalReplayDigest(after.facts) + const unchanged = beforeDigest === afterDigest + context.assert(MODULE, '审核重复拒绝后订单入仓码历史任务及唯一入库事实零漂移', after.response, + unchanged, `inboundCount=1, orderHistories=${after.facts.order.statusHistory.length}` + + `, inboundHistories=${after.facts.inbound.statusHistory.length}`, CONTRACT_OPTIONS) + const evidence = { marker: context.marker, tenantId, orderId, inboundId, inboundCode, + taskId: id(after.facts.task.id), inboundCount: after.facts.exactInbounds.length, + orderHistoryCount: after.facts.order.statusHistory.length, + inboundHistoryCount: after.facts.inbound.statusHistory.length, + beforeDigest, afterDigest, rejected, unchanged } + context.createdResources.orders.approvalReplay = evidence + return evidence +} function tomorrow(days = 30) { const date = new Date(); date.setUTCDate(date.getUTCDate() + days) return date.toISOString().slice(0, 10) @@ -742,6 +808,9 @@ export async function run(context) { throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法修改申报并向 OCR/墨家模块交接') } + await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, + inboundCode: id(inboundResult.detail?.summary?.inboundCode) }) + const editableDetailResponse = await context.request('GET', firstLegDetailPath) const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse) const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 89392a80b..6bce91243 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -4,7 +4,36 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs' +import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer, + crossContainerCartonProbe } from './phase4-overseas-pda.mjs' + +test('same-root FBA cross-container rejection uses explicit real receiving prefix while preserving original physical box code',() => { + const current={containerNo:'ROOT-CABINET',inbounds:[{inboundCode:'ROOT'}]} + const child={containerNo:'CHILD-CABINET',containerBookingId:'20',sourceOrderId:'101',sourceInboundId:'201', + sourceStatus:'LOADED',inboundCode:'CHILD',destinationType:'FBA',fulfillmentType:'STANDARD', + cartonMarks:['ROOT#2-1'],receivingCartonMarks:['CHILD#PDA-CONTRACT-1']} + const before=JSON.stringify(child),probe=crossContainerCartonProbe([child],current) + assert.equal(probe.cartonMark,'CHILD#PDA-CONTRACT-1') + assert.equal(probe.mode,'FBA_RECEIVING_PREFIX');assert.equal(probe.sourceOrderId,'101') + assert.equal(JSON.stringify(child),before) + for(const fault of [ + {...child,receivingCartonMarks:undefined}, {...child,receivingCartonMarks:['ROOT#PDA-CONTRACT-1']}, + {...child,receivingCartonMarks:['CHILD#PDA-CONTRACT-']}, {...child,receivingCartonMarks:['CHILD#PDA-CONTRACT-0']}, + {...child,containerNo:'ROOT-CABINET'}, {...child,sourceOrderId:null}, {...child,sourceInboundId:null}, + {...child,containerBookingId:null}, {...child,sourceStatus:'WAIT_SPLIT'}, {...child,inboundCode:'ROOT'}, + ])assert.equal(crossContainerCartonProbe([fault],current),null) + assert.equal(crossContainerCartonProbe([],current),null) + assert.equal(crossContainerCartonProbe([child],{containerNo:'ROOT-CABINET',inbounds:[]}),null) +}) +test('physical OVERSEAS and dropship probes still require another real loaded producer and keep the exact sealed code',() => { + const other={containerNo:'OTHER',containerBookingId:'20',sourceOrderId:'101',sourceInboundId:'201', + sourceStatus:'LOADED',inboundCode:'OTHER-SOURCE',destinationType:'OVERSEAS',fulfillmentType:'STANDARD', + cartonMarks:['ORIGINAL-PHYSICAL#2-1']} + const current={containerNo:'CURRENT',inbounds:[{inboundCode:'CURRENT-SOURCE'}]} + assert.equal(crossContainerCartonProbe([other],current).cartonMark,'ORIGINAL-PHYSICAL#2-1') + assert.equal(crossContainerCartonProbe([other],current).mode,'PHYSICAL_CARTON') + assert.equal(crossContainerCartonProbe([{...other,sourceInboundId:null}],current),null) +}) test('receiving selects one current booking and supplies mandatory booking identity', () => { assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 35a3a9994..55a13985d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -14,6 +14,27 @@ export function uniqueContainerProducer(item, index, all) { return nonBlank(item?.containerNo) && all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index } +export function crossContainerCartonProbe(producers, current) { + const currentCodes = new Set((current?.inbounds || []).map(item => id(item.inboundCode))) + if (!nonBlank(current?.containerNo) || !currentCodes.size) return null + for (const item of producers || []) { + if (!nonBlank(item?.containerNo) || item.containerNo === current?.containerNo + || !longId(item?.containerBookingId) || !longId(item?.sourceOrderId) + || !longId(item?.sourceInboundId) || item?.sourceStatus !== 'LOADED' + || !nonBlank(item?.inboundCode) || currentCodes.has(item.inboundCode)) continue + const standardFba = item.destinationType === 'FBA' && item.fulfillmentType === 'STANDARD' + const marks = standardFba ? item.receivingCartonMarks + : (Array.isArray(item.cartonMarks) ? item.cartonMarks : [item.cartonMark]) + const prefix = `${item.inboundCode}#PDA-CONTRACT-` + const cartonMark = (Array.isArray(marks) ? marks : []).map(id).find(value => nonBlank(value) + && (!standardFba || (value.startsWith(prefix) && /^[1-9][0-9]*$/.test(value.slice(prefix.length))))) + if (cartonMark) return { cartonMark, containerNo:item.containerNo, + containerBookingId:id(item.containerBookingId), inboundCode:item.inboundCode, + sourceOrderId:id(item.sourceOrderId), sourceInboundId:id(item.sourceInboundId), + mode:standardFba ? 'FBA_RECEIVING_PREFIX' : 'PHYSICAL_CARTON' } + } + return null +} function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response) { return String(response?.body?.code ?? '') } function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } @@ -525,14 +546,13 @@ async function probeCartonResolveNegatives(context, token, current) { { cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected) const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || [] - const crossContainerMark = allProducers - .filter((item) => id(item?.containerNo) !== id(current.containerNo)) - .flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark]) - .map(id).find(nonBlank) - if (crossContainerMark) { + const crossContainerProbe = crossContainerCartonProbe(allProducers,current) + if (crossContainerProbe) { + context.addStep(MODULE,'跨柜解析反证来源为另一真实实装柜及入仓码',null,true, + JSON.stringify(crossContainerProbe),OPTIONS) await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', - { cartonMark: crossContainerMark }, cartonResolveRejected) + { cartonMark: crossContainerProbe.cartonMark }, cartonResolveRejected) } else { blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') } diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index fa5f6287d..25b7b3f51 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,5 @@ import test from 'node:test' -import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -74,7 +74,9 @@ test('phase-five module proves receipt-save completion, cross-region convergence assert.match(source, /query\('\/api\/platform\/inbounds'/) assert.match(source, /inboundCode: inbound\.inboundCode/) assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/) - assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/) + assert.match(source, /verifyFulfillmentRootStatus\(context, \{ sourceOrderId, customerId, merchantToken \}\)/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(rootOrderId\)\}/) + assert.match(source, /\/fulfillment-tree/) assert.match(source, /mainStatus === 'COMPLETED'/) assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/) assert.match(source, /manualExceptionNo/) @@ -550,3 +552,108 @@ test('G05 failures still independently review and preserve raw, group, each SHIP assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter) } }) + +function rootCompletionFixture({ childStatus = 'PROCESSING', rootStatus = 'PROCESSING', rootDelivered = true, + fullyLoaded = false, mainStatus = rootStatus === 'COMPLETED' ? 'COMPLETED' : 'DELIVERED', sourceOrderId = '1001' } = {}) { + const root = { order: { id: '1001', tenantId: '100', customerId: '100', orderRole: 'MASTER', rootOrderId: '1001', + orderNo: 'FL1001', status: rootStatus }, statusHistory: [ + ...(rootDelivered ? [{ action: 'OVERSEAS_DELIVERED' }] : []), + ...(rootStatus === 'COMPLETED' ? [{ action: 'FULFILLMENT_COMPLETED' }] : [])] } + const child = { order: { id: '1002', tenantId: '100', customerId: '100', orderRole: 'CHILD', + rootOrderId: '1001', parentOrderId: '1001', orderNo: 'FL1001-1', status: childStatus } } + const tree = { parentOrderId: '1001', parentOrderNo: 'FL1001', originalPackageCount: 4, + cumulativeLoadedPackageCount: fullyLoaded ? 4 : 1, currentRemainderPackageCount: fullyLoaded ? 0 : 3, + children: [{ orderId: '1001', orderNo: 'FL1001', splitRole: 'LOADED', sourceOrderId: '1001', packageCount: 1 }, + { orderId: '1002', orderNo: 'FL1001-1', splitRole: 'REMAINDER', sourceOrderId: '1001', packageCount: 3 }, + ...(fullyLoaded ? [{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'LOADED', sourceOrderId: '1002', packageCount: 3 }] : [])] } + const context = contextFor((method, path) => { + if (path.includes('/fulfillment-tree')) return ok(tree) + if (path.includes('/first-leg/1002')) return ok(child) + if (path.includes('/first-leg/1001')) return ok(root) + return ok({ summary: { orderId: '1001', mainStatus } }) + }) + context.createdResources.phase5OverseasFulfillment = {} + return { root, child, tree, context, + args: { sourceOrderId, customerId: '100', merchantToken: 'ONLY-IN-MEMORY', pollAttempts: 1, pollIntervalMs: 0 } } +} + +test('real root1 signed plus effective remainder3 keeps raw PROCESSING and both platform/client DELIVERED', async () => { + const fixture = rootCompletionFixture() + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.passed, true) + assert.equal(result.expectedCompleted, false) + assert.deepEqual(result.pendingChildren.map(row => row.orderId), ['1002']) + assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), []) + const proof = fixture.context.createdResources.phase5OverseasFulfillment.orderCompletionProof + assert.equal(proof.tree.originalPackageCount, 4) + assert.equal(proof.tree.currentRemainderPackageCount, 3) + assert.deepEqual(proof.pendingChildOrderIds, ['1002']) + assert.doesNotMatch(JSON.stringify(proof), /ONLY-IN-MEMORY|password|accessToken|Bearer/) +}) + +test('missing, foreign, unknown or omitted real child cannot be treated as all fulfillment completed', async () => { + for (const mutate of [ + fixture => { fixture.child.order = null }, + fixture => { fixture.child.order.customerId = 'FOREIGN' }, + fixture => { fixture.child.order.rootOrderId = 'OTHER' }, + fixture => { fixture.child.order.parentOrderId = 'OTHER' }, + fixture => { fixture.child.order.orderNo = 'WRONG' }, + fixture => { fixture.child.order.status = undefined }, + fixture => { fixture.child.order.status = 'UNKNOWN' }, + fixture => { fixture.tree.children.pop() }, + fixture => { fixture.tree.children = undefined }, + ]) { + const fixture = rootCompletionFixture(); mutate(fixture) + assert.equal(await verifyFulfillmentRootStatus(fixture.context, fixture.args), null) + assert.ok(fixture.context.assertions.some(row => !row.passed)) + assert.equal(fixture.context.calls.some(call => call.path.startsWith('/api/merchant/')), false) + } +}) + +test('draft, rejected and pending child stay effective, and a premature root COMPLETED still fails', async () => { + for (const childStatus of ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'PROCESSING']) { + const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED' }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.expectedCompleted, false) + assert.equal(result.passed, false) + assert.equal(result.pendingChildren.length, 1) + assert.ok(fixture.context.assertions.some(row => !row.passed)) + } +}) + +test('actual completed child or explicit CANCELLED permits completion only with root own receipt and raw completion fact', async () => { + for (const childStatus of ['COMPLETED', 'CANCELLED']) { + const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED', fullyLoaded: childStatus === 'COMPLETED' }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.expectedCompleted, true) + assert.equal(result.passed, true) + assert.deepEqual(result.pendingChildren, []) + assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), []) + } + const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', fullyLoaded: true, rootDelivered: false }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.expectedCompleted, false) + assert.equal(result.passed, true) +}) + +test('receipt of a real child resolves and checks its owning root instead of completing the child as the aggregate', async () => { + const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, sourceOrderId: '1002' }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.rootOrderId, '1001') + assert.equal(result.passed, true) + assert.ok(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1001')) + assert.equal(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1002'), false) +}) + +test('all fragments completed still requires raw aggregate history and matching platform/customer completion', async () => { + const missingHistory = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true }) + missingHistory.root.statusHistory = [{ action: 'OVERSEAS_DELIVERED' }] + assert.equal((await verifyFulfillmentRootStatus(missingHistory.context, missingHistory.args)).passed, false) + const notProjected = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, mainStatus: 'DELIVERED' }) + assert.equal((await verifyFulfillmentRootStatus(notProjected.context, notProjected.args)).passed, false) + const foreignProjection = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true }) + const originalRequest = foreignProjection.context.request + foreignProjection.context.request = async (...args) => args[1].startsWith('/api/merchant/') + ? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args) + assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 5110e5a11..f136606d2 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -299,6 +299,102 @@ export function dispatchStockConservation(context, { customerId, warehouseId, di return { passed, proof, newLedger } } +// Completion follows the formal root and every real child; a signed shipment alone is not the aggregate. +export async function verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken, + pollAttempts = 45, pollIntervalMs = 1_000 }) { + const rawPath = orderId => query(`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { tenantId: customerId }) + const source = await platform(context, 'GET', '读取签收来源原始订单履约归属', rawPath(sourceOrderId)) + const sourceOrder = source.data?.order + const rootOrderId = sourceOrder?.orderRole === 'MASTER' ? id(sourceOrder.id) : id(sourceOrder?.rootOrderId) + const sourceOwned = success(source.response) && id(sourceOrder?.id) === sourceOrderId + && id(sourceOrder?.tenantId) === customerId && id(sourceOrder?.customerId) === customerId + && /^[1-9][0-9]*$/.test(rootOrderId) + && (sourceOrder.orderRole === 'MASTER' || (sourceOrder.orderRole === 'CHILD' && id(sourceOrder.parentOrderId) === rootOrderId)) + context.assert(MODULE, '签收来源订单属于本企业及唯一正式履约根', source.response, sourceOwned, `sourceOrderId=${sourceOrderId}, rootOrderId=${rootOrderId}`, OPTIONS) + if (!sourceOwned) return null + let root = rootOrderId === sourceOrderId ? source : await platform(context, 'GET', '读取原始履约根订单', rawPath(rootOrderId)) + const treeResult = await platform(context, 'GET', '读取签收后的完整正式履约树', + query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}/fulfillment-tree`, { tenantId: customerId })) + const tree = treeResult.data + const rows = Array.isArray(tree?.children) ? tree.children : [] + const treeOrderIds = new Set([rootOrderId, ...rows.map(row => id(row.orderId))]) + const consumed = new Set(rows.filter(row => row.splitRole === 'LOADED').map(row => id(row.sourceOrderId))) + const loaded = rows.filter(row => row.splitRole === 'LOADED').reduce((sum, row) => sum + Number(row.packageCount), 0) + const remaining = rows.filter(row => row.splitRole === 'REMAINDER' && !consumed.has(id(row.orderId))) + .reduce((sum, row) => sum + Number(row.packageCount), 0) + const treeComplete = success(root.response) && success(treeResult.response) + && id(root.data?.order?.id) === rootOrderId && root.data.order.orderRole === 'MASTER' + && id(root.data.order.tenantId) === customerId && id(root.data.order.customerId) === customerId + && Array.isArray(root.data.statusHistory) + && id(tree?.parentOrderId) === rootOrderId && tree.parentOrderNo === root.data.order.orderNo + && Array.isArray(tree.children) + && ['originalPackageCount', 'cumulativeLoadedPackageCount', 'currentRemainderPackageCount'].every(field => + tree[field] !== null && tree[field] !== undefined && Number.isSafeInteger(Number(tree[field])) && Number(tree[field]) >= 0) + && loaded === Number(tree.cumulativeLoadedPackageCount) && remaining === Number(tree.currentRemainderPackageCount) + && loaded + remaining === Number(tree.originalPackageCount) + && rows.every(row => /^[1-9][0-9]*$/.test(id(row.orderId)) && /^[1-9][0-9]*$/.test(id(row.sourceOrderId)) + && treeOrderIds.has(id(row.sourceOrderId)) + && nonBlank(row.orderNo) && ['LOADED', 'REMAINDER'].includes(row.splitRole) + && (row.splitRole === 'LOADED' ? id(row.orderId) === id(row.sourceOrderId) : id(row.orderId) !== id(row.sourceOrderId)) + && Number.isSafeInteger(Number(row.packageCount)) && Number(row.packageCount) > 0) + && (sourceOrderId === rootOrderId || rows.some(row => id(row.orderId) === sourceOrderId)) + context.assert(MODULE, '履约根及全部分片身份和树数量完整守恒', treeResult.response, treeComplete, + `rootOrderId=${rootOrderId}, loaded=${loaded}, remaining=${remaining}, original=${tree?.originalPackageCount ?? 'missing'}`, OPTIONS) + if (!treeComplete) return null + const children = [] + const statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'] + for (const childId of [...new Set(rows.map(row => id(row.orderId)).filter(value => value !== rootOrderId))]) { + const current = await platform(context, 'GET', '逐项读取真实履约子单完成事实', rawPath(childId)) + const child = current.data?.order + const owned = success(current.response) && id(child?.id) === childId && id(child?.tenantId) === customerId + && id(child?.customerId) === customerId && child?.orderRole === 'CHILD' + && id(child?.rootOrderId) === rootOrderId && id(child?.parentOrderId) === rootOrderId + && rows.filter(row => id(row.orderId) === childId).every(row => row.orderNo === child.orderNo) + && statuses.includes(child?.status) + context.assert(MODULE, '真实履约子单存在且客户根身份和状态有效', current.response, owned, `childOrderId=${childId}`, OPTIONS) + if (!owned) return null + children.push({ orderId: childId, orderNo: child.orderNo, status: child.status, rootOrderId }) + } + const pendingChildren = children.filter(child => !['COMPLETED', 'CANCELLED'].includes(child.status)) + const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true + const expectedCompleted = rootDelivered && pendingChildren.length === 0 + const owned = context.createdResources.phase5OverseasFulfillment + if (owned) owned.orderCompletionProof = { sourceOrderId, rootOrderId, + tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount), + cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining, + children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId), + splitRole: row.splitRole, packageCount: Number(row.packageCount) })) }, + children, rootDelivered, expectedCompleted, pendingChildOrderIds: pendingChildren.map(child => child.orderId) } + const domestic = await poll(() => context.request('GET', + query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}`, { tenantId: customerId })), + response => success(response) && id(response.body?.data?.summary?.orderId) === rootOrderId + && (expectedCompleted ? response.body.data.summary.mainStatus === 'COMPLETED' + : rootDelivered ? response.body.data.summary.mainStatus === 'DELIVERED' + : nonBlank(response.body.data.summary.mainStatus) && response.body.data.summary.mainStatus !== 'COMPLETED'), pollAttempts, pollIntervalMs) + if (expectedCompleted) root = await platform(context, 'GET', '全部有效分片结束后回查原始履约根', rawPath(rootOrderId)) + const rawPassed = success(root.response) && (expectedCompleted + ? root.data?.order?.status === 'COMPLETED' && root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED') + : statuses.includes(root.data?.order?.status) && root.data.order.status !== 'COMPLETED' + && !root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED')) + const platformPassed = success(domestic.result) && id(domestic.result?.body?.data?.summary?.orderId) === rootOrderId + && (expectedCompleted ? domestic.result.body.data.summary.mainStatus === 'COMPLETED' + : rootDelivered ? domestic.result.body.data.summary.mainStatus === 'DELIVERED' + : nonBlank(domestic.result.body.data.summary.mainStatus) && domestic.result.body.data.summary.mainStatus !== 'COMPLETED') + context.assert(MODULE, expectedCompleted ? '全部有效子单签收后原始履约根COMPLETED' : '有效余量或根票未签收时原始履约根不得提前COMPLETED', + root.response, rawPassed, `rootOrderId=${rootOrderId}, pendingChildren=${pendingChildren.map(row => row.orderNo).join(',')}`, OPTIONS) + context.addStep(MODULE, expectedCompleted ? '同订单全部有效履约分片签收后统一订单进入COMPLETED' : '本票签收但有效余量未完时统一订单保留未完成状态', + domestic.result, platformPassed, `rootOrderId=${rootOrderId}, pollRequests=${domestic.attempts}, mainStatus=${domestic.result?.body?.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + const customer = await merchant(context, merchantToken, 'GET', '客户读取本企业履约根真实完成状态', `/api/merchant/v1/orders/${encodeURIComponent(rootOrderId)}`) + const customerPassed = success(customer.response) && id(customer.data?.summary?.orderId) === rootOrderId + && customer.data?.summary?.mainStatus === domestic.result?.body?.data?.summary?.mainStatus && platformPassed + context.assert(MODULE, expectedCompleted ? '客户本企业全部履约完成后订单最终状态为COMPLETED' : '客户本企业仍有有效未完成履约时不得展示COMPLETED', + customer.response, customerPassed, `rootOrderId=${rootOrderId}, mainStatus=${customer.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (owned) Object.assign(owned.orderCompletionProof, { rawRootStatus: root.data?.order?.status, + platformMainStatus: domestic.result?.body?.data?.summary?.mainStatus, customerMainStatus: customer.data?.summary?.mainStatus, + rawPassed: Boolean(rawPassed), platformPassed, customerPassed }) + return { rootOrderId, expectedCompleted, pendingChildren, passed: Boolean(rawPassed && platformPassed && customerPassed) } +} + // A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) @@ -1012,19 +1108,8 @@ export async function run(context) { blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result) return } - const domesticOrder = await poll(async () => context.request('GET', - query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })), - (response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED') - context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, - success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', - `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId - const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态', - `/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`) - context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response, - customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED' - && id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId, - trace(customerCompletedOrder.response), OPTIONS) + await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken || await createSignedDocumentIsolationCustomer(context) await verifyCustomerSignedDocuments(context, merchantToken, diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs new file mode 100644 index 000000000..d237ea7b3 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, + completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials, + f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, run } from './phase5-root-fulfillment.mjs' +import { loadOneFragment } from './phase3-order-splitting.mjs' +import { verifyPalletLabels } from './phase3-loading-finalize.mjs' +import { awaitProducerConsumption, convertReceivingContainers, runReceiving, + resolveSharedWarehouse } from './phase4-overseas-pda.mjs' + +const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8') +const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action})) +const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2, + currentRemainderPackageCount:0,children:[ + {orderId:'100',sourceOrderId:'100',splitRole:'LOADED',packageCount:1}, + {orderId:'101',sourceOrderId:'100',splitRole:'REMAINDER',packageCount:1}, + {orderId:'101',sourceOrderId:'101',splitRole:'LOADED',packageCount:1}, + ]}) +const signed = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')}, + child:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED')}, + rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}}) +const outbox = () => ({readOnly:true,customerId:'90',rootOrderId:'100',orders:['100','101'].map(orderId => ({ + orderId,customerId:'90',rootOrderId:'100',eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED', + count:1,publishedCount:1,pendingCount:0,failedCount:0, +}))}) +const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdResources:{}, + addStep(_module,_label,_response,passed){this.steps.push({passed})}, + assert(_module,_label,_response,passed){this.steps.push({passed})}, + addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data}, +}) + +test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => { + const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8') + const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'} + const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now) + for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) { + assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field]) + } + assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03') + assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03') + assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP') + assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托') + for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field]) + assert.equal(body.fees.length,1) + assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])), + {feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10}) + assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/) + assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/) +}) +test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => { + const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'} + const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'} + assert.deepEqual(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:current}}), + {username:'fixture-customer',password:'fixture-secret',customerId:'90'}) + assert.deepEqual(resolveF40MerchantCredentials({options,context:{}}), + {username:'configured-customer',password:'configured-secret',customerId:null}) + for(const incomplete of [{username:'fixture-only',tenantId:'90'},{password:'fixture-only',tenantId:'90'}, + {username:'fixture-customer',password:'fixture-secret'}]) + assert.equal(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:incomplete}}),null) + assert.equal(resolveF40MerchantCredentials({options:{merchantUsername:'incomplete'},context:{}}),null) + assert.ok(source.indexOf("'F40 正式订单创建客户与本轮登录客户一致'") < source.indexOf("await stage(scope, 'inbound-measurement')")) +}) +test('F40 reads formal nested domestic cabinet workbench and compares booking/plan/task identity instead of inventing GET containers/id',() => { + const controller=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java',import.meta.url),'utf8') + const dto=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java',import.meta.url),'utf8') + assert.match(controller,/@GetMapping\("\/domestic\/\{id\}"\)/) + assert.doesNotMatch(controller,/@GetMapping\("\/\{id\}"\)/) + assert.match(dto,/record WorkbenchItem\(ContainerBookingEntity container, String workbenchStage/) + assert.match(source,/`\/api\/platform\/v1\/containers\/domestic\/\$\{result.containerId\}`/) + assert.doesNotMatch(source,/`\/api\/platform\/v1\/containers\/\$\{result.containerId\}`/) + const fragment={containerId:'20',planId:'30',taskId:'40',oceanBookingId:'50'},task={id:'40',containerBookingId:'20',loadingPlanId:'30'} + const workbench={container:{id:'20',cabinetNo:'D0001',containerNo:'OFFLINE',oceanBookingId:'50'}} + assert.equal(validFragmentCabinetIdentity(workbench,fragment,task),true) + assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,containerId:'21'},task),false) + assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,containerBookingId:'21'}),false) + assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,loadingPlanId:'31'}),false) + assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,id:'41'}),false) + assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,oceanBookingId:'51'},task),false) + assert.equal(validFragmentCabinetIdentity(workbench.container,fragment,task),false) +}) + +test('formal tree requires one root physical source plus its exact real remainder child and conserves two boxes',() => { + assert.equal(validTwoFragmentTree(tree(),'100','101'),true) + for (const change of [ + value => value.parentOrderId='999', value => value.currentRemainderPackageCount=1, + value => value.children[2].sourceOrderId='999', value => value.children[1].sourceOrderId='999', + value => value.children[2].packageCount=2, + value => value.children.push({splitRole:'UNKNOWN',orderId:'999'}), + ]) {const value=tree();change(value);assert.equal(validTwoFragmentTree(value,'100','101'),false)} +}) +test('F40 separates immutable physical task cartons from current actual FBA receiving prefix scans',() => { + assert.match(source,/cartonMarks: stableLoadedCartonMarks\(task.items\)/) + assert.match(source,/receivingCartonMarks:\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT-1`\]/) + assert.doesNotMatch(source,/cartonMarks:\s*\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT/) +}) +test('raw Order and original status history remain in evidence including operator, reason and timestamp',() => { + const detail={order:{id:'100',orderRole:'MASTER',version:3,status:'TRANSPORTING'},statusHistory:[ + {id:'11',orderId:'100',action:'OVERSEAS_DELIVERED',operatorName:'OVERSEAS_DELIVERY_EVENT', + reason:'signed actual delivery',occurredAt:'2026-09-30T01:00:00',fromStatus:'TRANSPORTING',toStatus:'TRANSPORTING'}, + ]} + const snapshot=fulfillmentHistorySnapshot(detail) + assert.deepEqual(snapshot.rawOrder,detail.order) + assert.deepEqual(snapshot.histories,detail.statusHistory) + assert.deepEqual(completionCounts(snapshot),{own:1,final:0}) +}) +test('root shipment signed first preserves unfinished root while effective child remains pending',() => { + const snapshot=signed();snapshot.root.status='TRANSPORTING' + snapshot.root.histories=history('OVERSEAS_DELIVERED');snapshot.child={status:'TRANSPORTING',histories:[]} + snapshot.rootInbound.orderStatusSnapshot='TRANSPORTING' + assert.equal(validSignatureGate(snapshot,'MASTER'),true) + snapshot.root.status='COMPLETED' + assert.equal(validSignatureGate(snapshot,'MASTER'),false) +}) +test('child signed before root remains an independent completed child and cannot finish unsigned root',() => { + const snapshot=signed();snapshot.root={status:'TRANSPORTING',histories:[]} + snapshot.rootInbound={processStatus:'IN_TRANSIT',orderStatusSnapshot:'TRANSPORTING'} + assert.equal(validSignatureGate(snapshot,'CHILD'),true) + assert.equal(validSignatureGate(snapshot,'MASTER'),false) + snapshot.root.histories=history('OVERSEAS_DELIVERED') + assert.equal(validSignatureGate(snapshot,'CHILD'),false) +}) +test('all signed requires root own fact, exactly one aggregate final history and real Firstleg delivered completion',() => { + assert.equal(validSignatureGate(signed(),'MASTER',true),true) + for(const change of [ + value => value.root.histories=history('FULFILLMENT_COMPLETED'), + value => value.root.histories.push({action:'FULFILLMENT_COMPLETED'}), + value => value.child.histories.push({action:'OVERSEAS_DELIVERED'}), + value => value.child.status='CANCELLED', + value => value.rootInbound.orderStatusSnapshot='TRANSPORTING', + value => value.rootInbound.processStatus='RECEIVED', + ]) {const value=signed();change(value);assert.equal(validSignatureGate(value,'MASTER',true),false)} +}) +test('Outbox requires exact customer/root/source aggregate membership, unique completion and PUBLISHED',() => { + assert.equal(exactCompletionOutboxProof(outbox(),'90','100',['101','100']),true) + for(const change of [ + value => value.readOnly=false, value => value.customerId='91', value => value.rootOrderId='999', + value => value.orders[0].rootOrderId='999', value => value.orders[0].customerId='91', + value => value.orders[1].orderId='100', value => value.orders[0].count=2, + value => value.orders[1].publishedCount=0, value => value.orders[1].pendingCount=1, + value => value.orders[0].failedCount=1, value => value.orders[0].eventType='OVERSEAS_DELIVERED', + value => value.orders.pop(), + ]) {const value=outbox();change(value);assert.equal(exactCompletionOutboxProof(value,'90','100',['100','101']),false)} +}) +test('exported fragment producer preserves fail closed supplier precondition and never writes without actual source master',async() => { + const context=fixture(),calls=[] + context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}} + assert.equal(await loadOneFragment(context,{sourceOrderNo:'offline-only'}),null) + assert.equal(calls.length,1) + assert.equal(calls[0][0],'GET') + assert.match(calls[0][1],/category=BOOKING&status=ACTIVE/) +}) +test('exported pallet label helper preserves FBA eligibility without introducing writes for other destinations',async() => { + const context=fixture();context.request=async() => {throw new Error('unexpected HTTP')} + assert.equal(await verifyPalletLabels(context,{destinationType:'OVERSEAS'},'1'),null) + assert.equal(await verifyPalletLabels(context,{destinationType:'FBA',fulfillmentType:'ONE_CLICK_DROPSHIP'},'1'),null) +}) +test('exported consumer polling and conversion fail closed when real producer consumption is absent',async() => { + const context=fixture(),calls=[] + context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}} + assert.equal(await awaitProducerConsumption(context,[]),true) + assert.equal(await convertReceivingContainers(context,[{containerNo:'OFFLINE'}]),false) + assert.equal(calls.every(([method]) => method==='GET'),true) + assert.equal(context.steps.at(-1).passed,false) +}) +test('exported receiving rejects unmatched current producer instead of borrowing any history container',async() => { + const context=fixture();context.request=async(_method,path) => ({http:200,body:{code:200,data: + path.endsWith('/candidates')?[{containerNo:'ANOTHER',sourceContainerBookingId:'999',totalPackages:1}]:null}}) + await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false) + assert.equal(context.steps.some(step => step.passed === false),true) +}) +test('exported receiving warehouse resolver continues to choose only configured active official warehouse',async() => { + const context=fixture();context.request=async() => ({http:200,body:{code:200,data:{records:[ + {id:'193000000000200003',warehouseType:'OVERSEAS',status:'ACTIVE',warehouseName:'offline'}, + ]}}}) + assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'}) +}) +test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback, SQL or cancellation substitute',() => { + assert.equal(typeof run,'function') + for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch', + 'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`)) + for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption', + 'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete']) + assert.match(source,new RegExp(`await ${name}\\(`)) + assert.match(source,/rootOrderId, sourceOrderIds:\[rootOrderId,childOrderId\]/) + assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/) + assert.match(source,/finally \{ proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/) + assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/cancel|\/complete-delivery/) +}) +test('early F40 failures become FAILED with only a safe stage and preserve the original exception',async() => { + const context=fixture();context.context={};context.options={} + await assert.rejects(run(context),/F40 本轮客户完整凭据有效/) + assert.equal(context.createdResources.phase5RootFulfillment.finalStatus,'FAILED') + assert.equal(context.createdResources.phase5RootFulfillment.failedStage,'CUSTOMER_IDENTITY') + assert.equal('error' in context.createdResources.phase5RootFulfillment,false) + const secretError=new Error('offline-secret-body-not-recorded') + const withLogin=fixture();withLogin.context={};withLogin.options={merchantUsername:'offline',merchantPassword:'offline'} + withLogin.request=async()=>{throw secretError} + await assert.rejects(run(withLogin),error=>error === secretError) + assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED') + assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 1b7848ab2..829071171 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -14,6 +14,40 @@ const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && String(response?.body?.code) === '200' const query = (path, values) => `${path}?${new URLSearchParams(values)}` +export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) } +export function resolveF40MerchantCredentials(context) { + const fixture = context.context?.merchantQualificationFixture + if (!fixture) { + const source = context.options + if (!source?.merchantUsername || !source?.merchantPassword) return null + return { username:source.merchantUsername, password:source.merchantPassword, customerId:null } + } + if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null + return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) } +} +export function f40TransferBody(marker, index, vehicle, now = new Date()) { + return { + idempotencyKey:`${marker}-F40-TRANSFER-${index}`, + plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)), + deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64), + carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET', + vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName, + driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate, + appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`, + fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10, + remark:`${marker}-acceptance`}], + } +} +export function validFragmentCabinetIdentity(workbench, fragment, task) { + const container = workbench?.container + return id(container?.id) === id(fragment?.containerId) + && Boolean(container?.cabinetNo && container?.containerNo) + && id(container?.oceanBookingId) === id(fragment?.oceanBookingId) + && id(task?.id) === id(fragment?.taskId) + && id(task?.containerBookingId) === id(fragment?.containerId) + && id(task?.loadingPlanId) === id(fragment?.planId) +} + export function fulfillmentHistorySnapshot(detail) { return { orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), @@ -124,9 +158,12 @@ async function fragment(context, source, rootOrderId, sequence) { check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`) const current = await api(context, 'GET', '回查该片真实入仓来源', query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) })) - const container = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/${result.containerId}`) + const workbench = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/domestic/${result.containerId}`) + const container = workbench?.container const task = await api(context, 'GET', '回查该片真实完成 PDA 任务', query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) })) + check(context,'F40 公开国内柜次详情和实际 PDA 的订舱柜次计划任务身份一致', + validFragmentCabinetIdentity(workbench,result,task),`containerBookingId=${result.containerId},planId=${result.planId},taskId=${result.taskId}`) check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED' && task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1, `source=${current?.summary?.inboundCode}`) @@ -138,6 +175,8 @@ async function fragment(context, source, rootOrderId, sequence) { warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo, destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId), destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items), + // Standard FBA resolves the actual receiving inbound prefix; transferred physical box codes remain unchanged. + receivingCartonMarks:[`${current.summary.inboundCode}#PDA-CONTRACT-1`], finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true, } context.createdResources.phase3Loading = loading @@ -167,14 +206,15 @@ async function sendFragment(context, producer, picker, merchantToken, index) { planPalletIds: matches.map(row => id(row.planPalletId)), }) const dispatchNo = dispatch.dispatchNo - await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, { + const vehicle = { vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', driverPhone: '19900000000', carrierName: 'F40测试车队', - estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), - }) + estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)), + } + await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle) const transfer = await api(context, 'POST', '客户正式创建该片派送单', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), - { idempotencyKey: `${context.marker}-F40-TRANSFER-${index}`, remark: context.marker }, merchantToken) + f40TransferBody(context.marker,index,vehicle), merchantToken) const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) @@ -200,15 +240,29 @@ async function sendFragment(context, producer, picker, merchantToken, index) { check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) + check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, - receiptVersion: completed.order.version } + receiptVersion: completed.order.version, signedAt: completed.order.signedAt } } /** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ export async function run(context) { + try { return await runRootFulfillment(context) } + catch (error) { + const proof = context.createdResources?.phase5RootFulfillment + if (proof) { + proof.finalStatus = 'FAILED' + proof.failedStage ||= proof.currentStage || 'INITIALIZING' + } + throw error + } +} +async function runRootFulfillment(context) { + const initialStepCount = context.steps?.length || 0 const proof = context.createdResources.phase5RootFulfillment = { marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null, fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null, + currentStage:'INITIALIZING', failedStage:null, cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence', } const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST' @@ -219,11 +273,26 @@ export async function run(context) { scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' }, waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, + phase4OverseasPdaOperations: { completedFixtures: [] }, allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` } scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } } + proof.currentStage = 'CUSTOMER_IDENTITY' + const credentials = resolveF40MerchantCredentials(scope) + check(scope,'F40 本轮客户完整凭据有效', Boolean(credentials), 'complete-credential-pair') + const login = await api(scope,'POST','F40 本轮客户正式登录','/api/auth/login', + {loginId:credentials.username,password:credentials.password},'') + const profile = await api(scope,'GET','F40 本轮客户会话归属核验', + '/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken) + const expectedCustomerId = id(profile.id) + check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId) + && (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer') await domesticLogin(scope) + proof.currentStage = 'ORDER_CREATION' await stage(scope, 'orders') const orderFixture = { ...scope.createdResources.orders } + check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId, + 'actual-order-customer-matches-profile') + proof.currentStage = 'DOMESTIC_MEASUREMENT' await stage(scope, 'inbound-measurement') const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId) proof.rootOrderId = rootOrderId; proof.customerId = customerId @@ -231,8 +300,10 @@ export async function run(context) { query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId })) check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT' && Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`) + proof.currentStage = 'ROOT_LOAD_AND_SHIP' const first = await fragment(scope, rootInbound, rootOrderId, 0) proof.fragments.push(first) + proof.currentStage = 'REMAINDER_IDENTITY' const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树', query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || [] @@ -253,8 +324,10 @@ export async function run(context) { const childInbound = await api(scope, 'GET', '读取子单可装柜来源', query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) scope.marker = `${scope.context.f40BaseMarker}-CHILD` + proof.currentStage = 'CHILD_LOAD_AND_SHIP' const second = await fragment(scope, childInbound, rootOrderId, 1) proof.fragments.push(second) + proof.currentStage = 'FULFILLMENT_TREE' const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树', query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId) @@ -264,26 +337,25 @@ export async function run(context) { && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' && before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures') proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before }) + proof.currentStage = 'OVERSEAS_PDA_IDENTITY' const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope) check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent') scope.marker = `${scope.context.f40BaseMarker}-PDA` const picker = await createPicker(scope, 7, warehouse.id, access) + try { check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') picker.warehouseId = warehouse.id - try { - const loginId = scope.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME - const password = scope.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD - const login = await api(scope, 'POST', 'F40 客户正式登录', '/api/auth/login', { loginId, password }, '') - const profile = await api(scope, 'GET', 'F40 客户会话归属核验', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken) check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] } scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null } + proof.currentStage = 'OVERSEAS_PLAN_CONVERSION' check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed') check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted') const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second] for (const [index, producer] of signatureSources.entries()) { + proof.currentStage = `OVERSEAS_RECEIVING_${index+1}` scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}` scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker scope.createdResources.phase4OverseasPda.marker = scope.marker @@ -295,15 +367,21 @@ export async function run(context) { && scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row => id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo), `container=${producer.containerNo}`) + proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}` const signed = await sendFragment(scope,producer,picker,login.accessToken,index) proof.signatures.push(signed) + proof.currentStage = `COMPLETION_GATE_${index+1}` const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId), value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1)) proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed }) check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次', validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder) } + proof.currentStage = 'RECEIPT_REPLAY' const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1) + check(scope,'F40 根完成时间为实际两片签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt, + `completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`) // Existing receipt helper replays the exact original save command before each signature observation. // Re-saving completed receipts with a fresh business key is also a no-transition operation. for (const [index,signed] of proof.signatures.entries()) { @@ -320,6 +398,7 @@ export async function run(context) { check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增', JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot') proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay}) + proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE' const reader = context.context?.f40ReadOnlyOutboxEvidence if (typeof reader === 'function') { proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId], @@ -336,7 +415,15 @@ export async function run(context) { } } catch (error) { proof.finalStatus = 'FAILED' + proof.failedStage = proof.currentStage throw error - } finally { await disablePicker(scope,picker,7) } + } finally { proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) } + if (proof.finalStatus === 'VERIFIED') { + const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false) + if (failedSteps.length) proof.finalStatus = 'FAILED' + check(context,'F40 正式链、重放及临时PDA停用所有检查均通过', failedSteps.length === 0, + `failed=${failedSteps.length}`) + } + proof.currentStage = 'FINISHED' return proof } diff --git a/scripts/database-schema-diff.mjs b/scripts/database-schema-diff.mjs index 243b8185a..efb92509f 100644 --- a/scripts/database-schema-diff.mjs +++ b/scripts/database-schema-diff.mjs @@ -57,6 +57,13 @@ function sqlStrings(values) { return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',') } +export function parseMetadataRows(output) { + return output.trim().split(/\r?\n/).filter(Boolean).map((line) => { + if (!/^(?:[0-9a-f]{2})+$/i.test(line)) throw new Error('数据库元数据输出不是有效 HEX JSON') + return JSON.parse(Buffer.from(line, 'hex').toString('utf8')) + }) +} + function runMysql(config, sql) { const executable = process.env.MYSQL_BIN || 'mysql' const result = spawnSync(executable, [ @@ -70,14 +77,14 @@ function runMysql(config, sql) { }) if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`) if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`) - return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)) + return parseMetadataRows(result.stdout) } function snapshot(config, schemas) { const selected = sqlStrings(schemas) - const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) - const tableRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, '')) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`) - const columnRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`) + const schemaRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', SCHEMA_NAME)) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) + const tableRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, ''))) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`) + const columnRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION)) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`) return { schemas: schemaRows, tables: tableRows, columns: columnRows } } diff --git a/scripts/database-schema-diff.test.mjs b/scripts/database-schema-diff.test.mjs index 91cd6c281..0aa8c0527 100644 --- a/scripts/database-schema-diff.test.mjs +++ b/scripts/database-schema-diff.test.mjs @@ -1,7 +1,20 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { compareSnapshots } from './database-schema-diff.mjs' +import { compareSnapshots, parseMetadataRows } from './database-schema-diff.mjs' + +test('metadata transport preserves Unicode, escaped control characters and expression defaults', () => { + const rows = [{ schema: 'sample', table: 'orders', defaultValue: '国内\r\n海外\t"quote"\\path', generation: "concat('x', '\\n')" }, { defaultValue: null }] + const output = rows.map(row => Buffer.from(JSON.stringify(row), 'utf8').toString('hex')).join('\r\n') + '\r\n' + assert.deepEqual(parseMetadataRows(output), rows) + assert.deepEqual(parseMetadataRows(''), []) +}) + +test('metadata transport rejects invalid or truncated records', () => { + assert.throws(() => parseMetadataRows('abc'), /HEX JSON/) + assert.throws(() => parseMetadataRows('not-json'), /HEX JSON/) + assert.throws(() => parseMetadataRows(Buffer.from('{"schema":').toString('hex')), SyntaxError) +}) test('database schema diff ignores column order but catches contract changes', () => { const left = { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index fdb4551bc..5a1e18355 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -6,6 +6,15 @@ import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') +test('F40 optional chain always creates its customer and rejects absent domestic PDA credentials', () => { + const options = parseArguments(['--modules', 'phase5-root-fulfillment', + '--gateway', 'http://localhost:9000', '--username', 'contract-user', '--password', 'contract-password', + '--merchant-username', 'prior-client', '--merchant-password', 'prior-password']) + assert.deepEqual(options.modules, ['merchant-qualification-fixture', 'phase5-root-fulfillment']) + options.pdaUsername = ''; options.pdaPassword = '' + assert.throws(() => validateExecutionOptions(options), /PDA/) +}) + test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { for (const module of [ 'phase4-overseas-warehouse', -- 2.54.0 From 71202bce70bebe6d30dda7fc075ed82411b4d40f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 15:26:35 +0800 Subject: [PATCH 038/176] fix(order): remove manual completion bypass of fulfillment gate --- deploy/sql/头程数据库统一增量-MySQL8.sql | 3 --- .../main/resources/db/phase1-admin-rbac.sql | 1 - .../PlatformFirstLegOrderController.java | 10 ------- .../service/OrderApplicationService.java | 5 ++-- .../src/main/resources/openapi/order-v1.yaml | 2 ++ .../OrderApplicationServiceStateTest.java | 26 +++++++++++++++++++ 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d0ea8a737..3d7234d90 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6067,7 +6067,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102012,0,'PLATFORM','BUTTON',5102010,'platform:order:reject','订单驳回',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102013,0,'PLATFORM','BUTTON',5102010,'platform:order:accept','订单受理',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102014,0,'PLATFORM','BUTTON',5102010,'platform:order:process','开始处理',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102015,0,'PLATFORM','BUTTON',5102010,'platform:order:complete','完成订单',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102016,0,'PLATFORM','BUTTON',5102010,'platform:order:cancel','取消订单',NULL,NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102017,0,'PLATFORM','BUTTON',5102010,'platform:order:note','内部备注',NULL,NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102018,0,'PLATFORM','BUTTON',5102010,'platform:order:fulfillment','登记履约快递',NULL,NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6449,7 +6448,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102013,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102014,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102015,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102018,0,CURRENT_TIMESTAMP(3)); @@ -6730,7 +6728,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102013,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102014,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102015,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102018,0,CURRENT_TIMESTAMP(3)); diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index f26c40e87..03020b153 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -101,7 +101,6 @@ VALUES (5102012, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:reject', '订单驳回', NULL, NULL, 20, 'ACTIVE', 0), (5102013, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:accept', '订单受理', NULL, NULL, 30, 'ACTIVE', 0), (5102014, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:process', '开始处理', NULL, NULL, 40, 'ACTIVE', 0), - (5102015, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:complete', '完成订单', NULL, NULL, 50, 'ACTIVE', 0), (5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0), (5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0), (5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0), diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index e94f99327..1473baf38 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -291,16 +291,6 @@ public class PlatformFirstLegOrderController { return transit(user, tenantId, id, OrderStatus.PROCESSING, null); } - @BusinessAudit(event = "ORDER_COMPLETE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Complete order / 完成订单") - @Operation(summary = "Complete / 完成订单", description = "Marks a processing order as completed and publishes the integration event. / 将处理中订单标记为完成,并发布集成事件。") - @PostMapping("/{id}/complete") - @PreAuthorize("hasAuthority('platform:order:complete')") - public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId, - @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) { - return transit(user, tenantId, id, OrderStatus.COMPLETED, null); - } - /** 仅履约权限可登记快递信息,商户端保持只读。 */ @PutMapping("/{id}/express") @PreAuthorize("hasAuthority('platform:order:fulfillment')") diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index afc691774..a31b3d0d9 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -174,6 +174,9 @@ public class OrderApplicationService { @Transactional public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason, Integer expectedVersion) { + if (target == OrderStatus.COMPLETED) { + throw new BizException(ResultCode.CONFLICT, "订单完成须由真实签收和全部有效履约分片完成后自动确认"); + } if (target == OrderStatus.CANCELLED) fulfillmentCompletion.lockCancellationRoot(tenantId, id); OrderEntity order = require(tenantId, id); if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) { @@ -205,7 +208,6 @@ public class OrderApplicationService { requirePrintableInboundCode(order); order.setSubmittedAt(LocalDateTime.now()); } - if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order); @@ -585,7 +587,6 @@ public class OrderApplicationService { case SUBMITTED -> target == OrderStatus.APPROVED || target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED; case APPROVED -> target == OrderStatus.ACCEPTED || target == OrderStatus.CANCELLED; case ACCEPTED -> target == OrderStatus.PROCESSING || target == OrderStatus.CANCELLED; - case PROCESSING -> target == OrderStatus.COMPLETED; case REJECTED -> target == OrderStatus.DRAFT || target == OrderStatus.CANCELLED; default -> false; }; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b27d06374..c31a53cb7 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -16,6 +16,8 @@ paths: 已完成才完成根订单;来源子单独立完成并在根锁下重新评估聚合。明确 CANCELLED 子单 不参与完成门禁,其他未完成状态均阻断。重复事实不得重复写历史。响应明确返回来源状态 status 和履约根 rootOrderId/rootStatus;根尚未聚合完成时允许保留其原状态。 + 订单完成仅由此可信履约回写和取消后聚合门禁产生,普通订单状态流转禁止目标 COMPLETED, + 平台不提供手工完成订单接口。 parameters: - name: X-Internal-Service-Token in: header diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 490879d19..00f440b1f 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -49,6 +49,32 @@ class OrderApplicationServiceStateTest { static class TestConfig { @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } } + @Test + void manualCompletionRejectsEveryStateAndRoleWithoutWritingFulfillmentFacts() { + for (String role : List.of("MASTER", "CHILD")) { + for (OrderStatus status : OrderStatus.values()) { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(status); + order.setOrderRole(role); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + clearInvocations(repository); + OrderFulfillmentCompletionService completion = mock(OrderFulfillmentCompletionService.class); + var app = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)); + ReflectionTestUtils.setField(app, "fulfillmentCompletion", completion); + + assertThatThrownBy(() -> app.transit(10001, 2, "u", 1, OrderStatus.COMPLETED, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("真实签收"); + assertThatThrownBy(() -> app.transit(10001, 2, "u", 1, OrderStatus.COMPLETED, null, 0)) + .isInstanceOf(BizException.class) + .hasMessageContaining("真实签收"); + verifyNoInteractions(repository, completion); + assertThat(order.getStatus()).isEqualTo(status); + assertThat(order.getCompletedAt()).isNull(); + } + } + } + @Test void cancellationLocksRootBeforeSourceWriteAndThenAggregates() { OrderRepository repository = mock(OrderRepository.class); -- 2.54.0 From 1a7e4800f5c6d46be3579bf4ecdbf34d9713a09c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 15:28:50 +0800 Subject: [PATCH 039/176] fix(firstleg): derive remainder pallet labels from frozen allocation facts --- .../mapper/LoadingPalletLabelMapper.java | 12 ++ .../mapper/LoadingPalletLabelMapperTest.java | 143 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapperTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java index 6b05deda6..faca5e4c3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -11,12 +11,24 @@ import java.util.List; @Mapper public interface LoadingPalletLabelMapper { + // Remainder slices inherit bound slots, not new weight records. Their slot/weight + // identity survives release or transfer; preserved empty slots are not pallets. @Select(""" SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id, i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, i.destination_warehouse_code,i.destination_warehouse_name, COALESCE(NULLIF(i.pallet_count,0), CEIL(COALESCE(NULLIF(o.pallet_count,0), + NULLIF((SELECT COUNT(DISTINCT weight.id) + FROM fl_inbound_pallet_allocation allocation + JOIN fl_inbound_weight_result weight + ON weight.id=allocation.weight_result_id AND weight.tenant_id=allocation.tenant_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED' + AND (weight.inbound_id=o.id OR weight.inbound_id=o.root_inbound_id) + WHERE allocation.tenant_id=i.account_id AND allocation.inbound_id=i.inbound_id + AND allocation.block_id>0 AND allocation.slot_no>0 AND allocation.reservation_id>0 + AND allocation.allocated_package_count>0 + AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), (SELECT COUNT(*) FROM fl_inbound_weight_result weight WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapperTest.java new file mode 100644 index 000000000..c5e09cb6d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapperTest.java @@ -0,0 +1,143 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +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 java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Execute the production MyBatis query, including inherited weight/slot joins. */ +class LoadingPalletLabelMapperTest { + private SqlSession session; + + @BeforeEach + void schema() throws Exception { + var dataSource = new UnpooledDataSource("org.h2.Driver", + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL", "sa", ""); + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(LoadingPalletLabelMapper.class); + session = new SqlSessionFactoryBuilder().build(configuration).openSession(); + execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR,plan_status VARCHAR,container_booking_id BIGINT)"); + execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,container_no VARCHAR,cabinet_no VARCHAR,warehouse_id BIGINT,record_status VARCHAR)"); + // MySQL '/' preserves fractional results for integer operands; H2's integer + // division truncates. Decimal scale zero models MySQL's existing CEIL formula. + execute("CREATE TABLE fl_loading_plan_item(id BIGINT PRIMARY KEY,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,destination_warehouse_id BIGINT,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,pallet_count INT,package_count DECIMAL(10,0),load_sequence INT,deleted INT)"); + execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,root_inbound_id BIGINT,pallet_count INT,total_package_count DECIMAL(10,0),fulfillment_type VARCHAR,destination_type VARCHAR,deleted INT)"); + execute("CREATE TABLE fl_inbound_weight_result(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,recognition_status VARCHAR,deleted INT)"); + execute("CREATE TABLE fl_inbound_pallet_allocation(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,block_id BIGINT,slot_no INT,reservation_id BIGINT,weight_result_id BIGINT,allocated_package_count INT,picked_package_count INT,allocation_status VARCHAR)"); + execute("INSERT INTO fl_loading_plan VALUES(1,'LP-1','LOADING',2)"); + execute("INSERT INTO fl_container_booking VALUES(2,'MSCU6639870','D0001',3,'ACTIVE')"); + execute("INSERT INTO fl_inbound_order VALUES(20,100,30,0,2,'STANDARD','FBA',0)"); + execute("INSERT INTO fl_loading_plan_item VALUES(10,1,100,20,'IN-CHILD',200,'ONT8','ONT8',0,2,1,0)"); + } + + @AfterEach + void close() { if (session != null) session.close(); } + + private void execute(String sql) throws Exception { + try (var statement = session.getConnection().createStatement()) { statement.execute(sql); } + session.clearCache(); + } + + private void inherited(long id, int slot, int boxes) throws Exception { + execute("INSERT INTO fl_inbound_weight_result VALUES(" + id + ",100,30,900,'CONFIRMED',0)"); + execute("INSERT INTO fl_inbound_pallet_allocation VALUES(" + id + ",100,20,600," + slot + + "," + (700 + slot) + "," + id + "," + boxes + ",0,'CONFIRMED')"); + } + + private int count() { + var rows = session.getMapper(LoadingPalletLabelMapper.class).lockSources(1L); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getInboundId()).isEqualTo(20L); + assertThat(rows.get(0).getAccountId()).isEqualTo(100L); + return rows.get(0).getPalletCount(); + } + + @Test + void childUsesInheritedConfirmedSlotWithoutCreatingItsOwnWeightAndKeepsReleasedSnapshot() throws Exception { + inherited(50, 1, 2); + assertThat(count()).isEqualTo(1); + execute("UPDATE fl_inbound_pallet_allocation SET picked_package_count=allocated_package_count,allocation_status='RELEASED'"); + assertThat(count()).isEqualTo(1); + execute("UPDATE fl_inbound_pallet_allocation SET allocation_status='TRANSFERRED'"); + assertThat(count()).isEqualTo(1); + try (var statement = session.getConnection().createStatement(); + var result = statement.executeQuery("SELECT COUNT(*) FROM fl_inbound_weight_result WHERE inbound_id=20")) { + result.next(); assertThat(result.getInt(1)).isZero(); + } + } + + @Test + void countsWeightSlotIdentityOnceAndDoesNotCollapseReusedPhysicalPalletIds() throws Exception { + inherited(50, 1, 1); + execute("INSERT INTO fl_inbound_pallet_allocation VALUES(51,100,20,601,1,702,50,1,0,'CONFIRMED')"); + assertThat(count()).isEqualTo(1); + inherited(52, 2, 1); + assertThat(count()).isEqualTo(2); + } + + @ParameterizedTest + @ValueSource(strings = {"empty", "no-weight", "wrong-customer", "wrong-child", "other-child-weight", + "unconfirmed", "deleted-weight", "invalid-block", "invalid-slot", "invalid-reservation", "reserved"}) + void invalidOrUnrelatedSnapshotsNeverInventOnePallet(String scenario) throws Exception { + inherited(50, 1, 2); + execute(switch (scenario) { + case "empty" -> "UPDATE fl_inbound_pallet_allocation SET allocated_package_count=0"; + case "no-weight" -> "UPDATE fl_inbound_pallet_allocation SET weight_result_id=NULL"; + case "wrong-customer" -> "UPDATE fl_inbound_weight_result SET tenant_id=101"; + case "wrong-child" -> "UPDATE fl_inbound_pallet_allocation SET inbound_id=21"; + case "other-child-weight" -> "UPDATE fl_inbound_weight_result SET inbound_id=21"; + case "unconfirmed" -> "UPDATE fl_inbound_weight_result SET recognition_status='PENDING'"; + case "deleted-weight" -> "UPDATE fl_inbound_weight_result SET deleted=1"; + case "invalid-block" -> "UPDATE fl_inbound_pallet_allocation SET block_id=0"; + case "invalid-slot" -> "UPDATE fl_inbound_pallet_allocation SET slot_no=0"; + case "invalid-reservation" -> "UPDATE fl_inbound_pallet_allocation SET reservation_id=0"; + case "reserved" -> "UPDATE fl_inbound_pallet_allocation SET allocation_status='RESERVED'"; + default -> throw new IllegalArgumentException(scenario); + }); + assertThat(count()).isZero(); + } + + @Test + void preservesPlanThenInboundPriorityAndRoundsActualSliceUp() throws Exception { + inherited(50, 1, 1); inherited(51, 2, 1); inherited(52, 3, 1); + execute("UPDATE fl_inbound_order SET total_package_count=8"); + execute("UPDATE fl_loading_plan_item SET package_count=3"); + assertThat(count()).isEqualTo(2); + execute("UPDATE fl_inbound_order SET pallet_count=4"); + assertThat(count()).isEqualTo(2); + execute("UPDATE fl_loading_plan_item SET pallet_count=5"); + assertThat(count()).isEqualTo(5); + } + + @ParameterizedTest + @ValueSource(strings = {"NULL", "0"}) + void rootWithoutRootPointerStillUsesItsOwnConfirmedWeight(String rootId) throws Exception { + execute("UPDATE fl_inbound_order SET root_inbound_id=" + rootId); + execute("INSERT INTO fl_inbound_weight_result VALUES(50,100,20,900,'CONFIRMED',0)"); + assertThat(count()).isEqualTo(1); + execute("INSERT INTO fl_inbound_pallet_allocation VALUES(50,100,20,600,1,701,50,2,0,'CONFIRMED')"); + assertThat(count()).isEqualTo(1); + } + + @Test + void anotherChildMayOnlyUseASourceWeightThroughItsOwnSnapshot() throws Exception { + inherited(50, 1, 2); + execute("INSERT INTO fl_inbound_order VALUES(21,100,30,0,2,'STANDARD','FBA',0)"); + execute("UPDATE fl_loading_plan_item SET inbound_id=21,inbound_code='OTHER-CHILD'"); + assertThat(session.getMapper(LoadingPalletLabelMapper.class).lockSources(1L).get(0).getPalletCount()).isZero(); + execute("INSERT INTO fl_inbound_pallet_allocation VALUES(51,100,21,600,1,701,50,2,0,'CONFIRMED')"); + assertThat(session.getMapper(LoadingPalletLabelMapper.class).lockSources(1L).get(0).getPalletCount()).isEqualTo(1); + } +} -- 2.54.0 From b436f3cf1ff82714fbbbff9f838a35a924cfa151 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 16:35:03 +0800 Subject: [PATCH 040/176] fix(lastleg): retain location code in immutable stock receipts --- .../inboundplan/pda/service/InboundStockReceiptService.java | 1 + .../pda/service/InboundStockReceiptServiceTest.java | 3 +++ 2 files changed, 4 insertions(+) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 9a080980d..d28ad8587 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -277,6 +277,7 @@ public class InboundStockReceiptService { receipt.setPalletNo(pallet.getPalletNo()); receipt.setSourcePalletNo(pallet.getSourcePalletNo()); receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setWarehouseLocationCode(warehouseLocationCode); receipt.setBatchNo(plan.getPlanNo()); receipt.setReceivedPackages(packages); receipt.setInventoryBalanceId(result.balanceId()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 4775eff2a..79430bd4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -103,6 +103,9 @@ class InboundStockReceiptServiceTest { assertEquals(5L, receipt.getValue().getReceivedPackages()); assertEquals(10L, receipt.getValue().getInventoryPositionId()); assertEquals("PLT-21", receipt.getValue().getPalletNo()); + assertEquals(3001L, receipt.getValue().getWarehouseLocationId()); + assertEquals("PDA-LAX-3001", receipt.getValue().getWarehouseLocationCode()); + assertEquals(request.getValue().warehouseLocationCode(), receipt.getValue().getWarehouseLocationCode()); assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus()); assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus()); ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); -- 2.54.0 From 94881d23fdcb4a7f3500de0bf2104ce257ff21df Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 16:35:05 +0800 Subject: [PATCH 041/176] test(firstleg): verify ship exception persistence and rollback --- ...rrierScheduleExceptionPersistenceTest.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java new file mode 100644 index 000000000..55c97e44e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java @@ -0,0 +1,132 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; +import com.baiyue.firstleg.portschedule.entity.*; +import com.baiyue.firstleg.portschedule.mapper.*; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.lang.reflect.Modifier; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.*; + +/** Real production service/mapper writes against H2; provider inputs are synthetic, + * so this does not claim deployed third-party/runtime acceptance. */ +class CarrierScheduleExceptionPersistenceTest { + private static final LocalDateTime TIME = LocalDateTime.of(2026, 9, 30, 12, 0); + private JdbcTemplate jdbc; + private CarrierScheduleQueryService service; + + @BeforeEach + void setup() throws Exception { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:carrier-exception-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(dataSource); + for (Class entity : List.of(CarrierScheduleQueryEntity.class, CarrierScheduleQueryHistoryEntity.class, + CarrierScheduleExceptionFactEntity.class, CarrierScheduleNodeFactEntity.class)) createTable(entity); + jdbc.execute("ALTER TABLE fl_carrier_schedule_exception_fact ADD CONSTRAINT uq_exception UNIQUE(query_id,event_key)"); + jdbc.update("INSERT INTO fl_carrier_schedule_query(id,query_no,customer_id,ocean_booking_id,container_booking_id," + + "carrier_code,tracking_type,tracking_no,status,trigger_source,latest_standard_node,latest_node_observed_at,version)" + + " VALUES(1,'CQ-TEST',0,2,3,'MATSON','CONTAINER_NO','TSTU0000010','READY','OUTBOUND_CONFIRM','IN_TRANSIT',?,7)", TIME); + jdbc.update("INSERT INTO fl_carrier_schedule_node_fact(id,query_id,container_booking_id,event_key,provider_code," + + "provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,actual_at,raw_payload_hash)" + + " VALUES(4,1,3,'MATSON:normal','MATSON','normal','TRANSIT','IN_TRANSIT',50,?,?,?)", TIME, TIME, "c".repeat(64)); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + for (Class mapper : List.of(CarrierScheduleQueryMapper.class, CarrierScheduleQueryHistoryMapper.class, + CarrierScheduleExceptionFactMapper.class, CarrierScheduleNodeFactMapper.class)) configuration.addMapper(mapper); + MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + SqlSessionTemplate session = new SqlSessionTemplate(factory.getObject()); + CarrierScheduleQueryRepository repository = new CarrierScheduleQueryRepository(); + ReflectionTestUtils.setField(repository, "queries", session.getMapper(CarrierScheduleQueryMapper.class)); + ReflectionTestUtils.setField(repository, "histories", session.getMapper(CarrierScheduleQueryHistoryMapper.class)); + ReflectionTestUtils.setField(repository, "exceptions", session.getMapper(CarrierScheduleExceptionFactMapper.class)); + ReflectionTestUtils.setField(repository, "nodes", session.getMapper(CarrierScheduleNodeFactMapper.class)); + CarrierScheduleQueryService target = new CarrierScheduleQueryService(); + ReflectionTestUtils.setField(target, "repository", repository); + ReflectionTestUtils.setField(target, "objectMapper", new ObjectMapper().findAndRegisterModules()); + ProxyFactory proxy = new ProxyFactory(target); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource), + new AnnotationTransactionAttributeSource())); + service = (CarrierScheduleQueryService) proxy.getProxy(); + } + + @Test + void fourHoldAndReleaseFactsPersistIndependentlyAlongsideUnchangedNormalProgress() { + for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) { + service.applyProviderException(1, request(code, code + "-ON", true, "a")); + service.applyProviderException(1, request(code, code + "-OFF", false, "b")); + } + var detail = service.detail(1); + assertThat(detail.exceptions()).hasSize(8); + assertThat(detail.exceptions().stream().filter(fact -> fact.active()).count()).isEqualTo(4); + assertThat(detail.histories()).hasSize(8).allMatch(history -> history.action().equals("EXCEPTION_OBSERVED")); + assertThat(detail.nodes()).hasSize(1); + assertThat(detail.query().query().latestActualNode()).isEqualTo("IN_TRANSIT"); + assertThat(detail.query().query().version()).isEqualTo(7); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8); + } + + @Test + void sameEventHashReplayAddsNoFactOrHistoryAndDifferentHashCannotOverwrite() { + var request = request("CUSTOMS_HOLD", "hold-original", true, "a"); + service.applyProviderException(1, request); + service.applyProviderException(1, request); + assertThat(service.detail(1).exceptions()).hasSize(1); + assertThat(service.detail(1).histories()).hasSize(1); + assertThatThrownBy(() -> service.applyProviderException(1, + request("CUSTOMS_HOLD", "hold-original", true, "b"))) + .hasMessageContaining("rawPayloadHash"); + assertThat(service.detail(1).exceptions()).hasSize(1); + assertThat(service.detail(1).exceptions().get(0).rawPayloadHash()).isEqualTo("a".repeat(64)); + assertThat(service.detail(1).histories()).hasSize(1); + } + + @Test + void failedHistoryWriteRollsBackExceptionAndOriginalRequestCanCommitAfterRepair() { + jdbc.execute("ALTER TABLE fl_carrier_schedule_query_history ADD CONSTRAINT reject_observation CHECK(action <> 'EXCEPTION_OBSERVED')"); + var request = request("DOCUMENT_HOLD", "rollback-original", true, "a"); + assertThatThrownBy(() -> service.applyProviderException(1, request)).isInstanceOf(RuntimeException.class); + assertThat(service.detail(1).exceptions()).isEmpty(); + assertThat(service.detail(1).histories()).isEmpty(); + jdbc.execute("ALTER TABLE fl_carrier_schedule_query_history DROP CONSTRAINT reject_observation"); + service.applyProviderException(1, request); + assertThat(service.detail(1).exceptions()).hasSize(1); + assertThat(service.detail(1).histories()).hasSize(1); + assertThat(service.detail(1).query().query().latestActualNode()).isEqualTo("IN_TRANSIT"); + } + + private ProviderExceptionRequest request(String code, String eventId, boolean active, String hash) { + return new ProviderExceptionRequest("MATSON", eventId, active ? code : code + " RELEASED", code, + active, active ? TIME : TIME.plusMinutes(1), "USLAX", "Los Angeles", "synthetic-test:" + eventId, hash.repeat(64)); + } + + private void createTable(Class entity) { + String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(field -> field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + + " " + (field.getType() == Long.class ? "BIGINT" : field.getType() == Integer.class ? "INT" + : field.getType() == Boolean.class ? "BOOLEAN" : field.getType() == LocalDateTime.class ? "TIMESTAMP(3)" : "VARCHAR(16000)") + + (field.getName().equals("id") ? " PRIMARY KEY" : "")) + .collect(Collectors.joining(",")); + jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + "(" + columns + ")"); + } +} -- 2.54.0 From babc7de4b635253b939a46f036e610aba6593ed1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 16:51:49 +0800 Subject: [PATCH 042/176] fix(order): require dropship SKU before submission --- .../service/OrderApplicationService.java | 12 +++- .../src/main/resources/openapi/order-v1.yaml | 2 +- .../OrderApplicationServiceStateTest.java | 66 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index a31b3d0d9..c3e71b849 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -401,6 +401,7 @@ public class OrderApplicationService { "提交订单前必须完善货物品名、包装类型、箱数、毛重和体积"); } validatePersistedCargoQuantity(cargo, cargoIndex, true); + requireDropshipSku(order.getFulfillmentType(), cargo.getSkuNo(), cargoIndex); if (Boolean.TRUE.equals(cargo.getWoodenProduct()) && !Boolean.TRUE.equals(order.getCustomsInspection())) { throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检"); } @@ -441,7 +442,8 @@ public class OrderApplicationService { } validateWoodInspection(request); validateRequestCargoQuantities(request.cargoes(), true); - for (var cargo : request.cargoes()) { + for (int cargoIndex = 0; cargoIndex < request.cargoes().size(); cargoIndex++) { + var cargo = request.cargoes().get(cargoIndex); if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType()) || cargo.packageCount() < 1 || cargo.grossWeightKg() == null || cargo.grossWeightKg().signum() <= 0 || cargo.volumeCbm() == null @@ -449,6 +451,7 @@ public class OrderApplicationService { throw new BizException(ResultCode.VALIDATION_FAILED, "提交订单前必须完善货物品名、包装类型、箱数、毛重和体积"); } + requireDropshipSku(request.fulfillmentType(), cargo.skuNo(), cargoIndex); } validateMixedCartons(request); if ("FBA".equals(request.destinationType())) { @@ -467,6 +470,13 @@ public class OrderApplicationService { } } + private static void requireDropshipSku(String fulfillmentType, String skuNo, int cargoIndex) { + if ("ONE_CLICK_DROPSHIP".equals(fulfillmentType) && !StringUtils.hasText(skuNo)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "第 " + (cargoIndex + 1) + " 条一件代发货物必须填写 SKU NO."); + } + } + private void validateRequestCargoQuantities(List cargoes, boolean required) { for (int index = 0; index < cargoes.size(); index++) { OrderRequests.Cargo cargo = cargoes.get(index); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c31a53cb7..c1ea54b99 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -1399,7 +1399,7 @@ components: type: object required: [packageCount, woodenProduct] properties: - skuNo: {type: string, maxLength: 64} + skuNo: {type: string, maxLength: 64, description: 草稿可空;FBA 或 ONE_CLICK_DROPSHIP 订单提交及已提交编辑时每条货物必填} cargoName: {type: string, maxLength: 128} cargoNameEn: {type: string, maxLength: 128} productImageFileId: {type: string, pattern: '^[1-9][0-9]*$'} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 00f440b1f..1417e4afc 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -375,6 +375,72 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(20002L, order); } + @Test + void submittingDropshipDraftRequiresEachPersistedSkuBeforeAnyStateWriteForBothTaxModes() { + for (String taxMode : List.of("BUY_DECLARATION", "TAX_REFUND")) { + for (String sku : java.util.Arrays.asList(null, "", " ")) { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + order.setFulfillmentType("ONE_CLICK_DROPSHIP"); order.setTaxMode(taxMode); + OrderCargoEntity valid = completeCargo(); valid.setSkuNo("SKU-VALID"); + OrderCargoEntity missing = completeCargo(); missing.setSkuNo(sku); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(valid, missing)); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .submit(20002L, 2L, "u", 1L, "missing-sku")) + .isInstanceOf(BizException.class).hasMessage("第 2 条一件代发货物必须填写 SKU NO.") + .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); + assertThat(order.getSubmittedAt()).isNull(); assertThat(order.getInboundCode()).isNull(); + verify(repository, never()).saveOrder(anyLong(), any()); + verify(repository, never()).insertOutbox(any()); + verify(repository, never()).insertHistory(any()); + verify(repository, never()).completeIdempotency(anyLong(), any(), anyLong()); + } + } + } + + @Test + void submittingDropshipDraftWithSkuKeepsUpcOptional() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + order.setFulfillmentType("ONE_CLICK_DROPSHIP"); + OrderCargoEntity cargo = completeCargo(); cargo.setSkuNo("SKU-VALID"); cargo.setUpcNo(null); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo)); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + when(repository.saveOrder(20002L, order)).thenReturn(1); + service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); + assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); + verify(repository).saveOrder(20002L, order); + } + + @Test + void submittedDropshipEditorCannotRemoveRequiredSku() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + OrderRequests.SaveRequest source = request(); + OrderRequests.SaveRequest missingSku = new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP"); + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .updateSubmittedOrder(10001L, 2L, 1L, missingSku)) + .isInstanceOf(BizException.class).hasMessage("第 1 条一件代发货物必须填写 SKU NO.") + .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).saveOrder(anyLong(), any()); + verify(repository, never()).replaceDraftChildren(anyLong(), anyLong()); + verify(repository, never()).insertHistory(any()); + } + @Test void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() { OrderRepository repository = mock(OrderRepository.class); -- 2.54.0 From 32fb9ec7b35e434d11023e15ccd57ca6dd4730f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 17:13:11 +0800 Subject: [PATCH 043/176] =?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 | 4 +- auth/AGENTS.md | 2 +- common-web/AGENTS.md | 4 +- event-bridge-service/AGENTS.md | 4 +- finance/AGENTS.md | 2 +- firstleg/AGENTS.md | 2 +- gateway-overseas/AGENTS.md | 6 +- lastleg-service/AGENTS.md | 5 +- .../service/OrderApplicationService.java | 22 +- .../OrderApplicationServiceStateTest.java | 112 ++++++++ scripts/admin-test-env-modules/README.md | 8 +- ...easurement-pallet-freeze-contract.test.mjs | 37 +++ .../inbound-measurement.mjs | 118 +++++++- ...rders-cargo-requirements-contract.test.mjs | 34 +++ .../orders-contract.test.mjs | 65 +++++ .../orders-pricing-fixture-contract.test.mjs | 11 + scripts/admin-test-env-modules/orders.mjs | 116 ++++++-- ...port-schedules-retention-contract.test.mjs | 83 ++++++ .../phase3-port-schedules.mjs | 75 +++++ .../phase4-overseas-pda-contract.test.mjs | 136 ++++++++- .../phase4-overseas-pda.mjs | 193 ++++++++++++- ...se5-overseas-fulfillment-contract.test.mjs | 25 +- .../phase5-overseas-fulfillment.mjs | 31 +++ .../phase5-root-fulfillment-contract.test.mjs | 200 +++++++++++++- .../phase5-root-fulfillment.mjs | 257 ++++++++++++++++-- scripts/agents-index-contract.ps1 | 2 +- 26 files changed, 1484 insertions(+), 70 deletions(-) create mode 100644 scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/orders-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 78460ce9e..28b316cf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # 头程与海外仓后端项目指引 -> 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。 +> 当前为单 TEST、统一 Gateway/Auth 会话/Nacos 与业务 Outbox 直接投递。架构规则及部署/验收状态唯一见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`。模块旧双区域说明不能覆盖这两份契约;构建、部署与整体验收分别核对。 根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。 @@ -27,7 +27,7 @@ - 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。 - 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。 - 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。 -- 后端指引累计超过约 250 行时优先用 `rg` 读取命中段;聚合构建或架构任务才全文读取 `README.md`、`pom.xml` 和目录结构文档。 +- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。 | 任务关键词 | 首选服务 | |---|---| diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 2fc5501e1..5decc247f 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -5,7 +5,7 @@ - Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 -- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/统一认证与服务通信契约.md`。 +- 修改统一 Access/Refresh Token、Audience、JWKS、Redis 会话或权限撤销前,先读 `../../docs/统一认证与服务通信契约.md` 的认证章节;平台、客户及两类 PDA 保持各自访问域和仓绑定边界。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index edc326106..3820a631b 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -25,8 +25,8 @@ ## 修改与验证 - 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。 -- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 -- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 +- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503 与 Audit 202 仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 +- 修改自动配置时验证 `audit-service` 等当前构建内、只扫描自身包的直接消费者仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`;按根 POM 与实际依赖限定范围。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 890bd77e2..85e616a0d 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -1,5 +1,7 @@ # Event Bridge Service 模块指引 +> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。事件任务先按 `../../docs/统一认证与服务通信契约.md` 的逐事件表定位生产者/直接消费者与业务 Relay;不得恢复区域分派或重新加入构建。源码清理按 `../../docs/测试环境单机部署与配置契约.md` 核对引用及运行证据。 + ## 职责边界 - Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 @@ -36,5 +38,5 @@ ## 修改与验证 -- 定向验证:`mvn -pl event-bridge-service -am test`。 +- 本模块已退出聚合,不能从根使用 `mvn -pl event-bridge-service`;现行投递验证直接生产者/消费者及对应 Outbox/Inbox、ACK、重试和审计。 - 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 diff --git a/finance/AGENTS.md b/finance/AGENTS.md index cffd9fd56..fdb7f6c70 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -3,7 +3,7 @@ ## 模块定位 - 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。 -- 产品范围先读 `../../docs/财务管理/README.md`、`../../docs/财务管理/财务领域模型与跨服务契约.md` 和 `Firstleg-Finance跨服务契约.md`;产品确认表、五页验收契约和证据矩阵只在状态核对或验收任务中读取。只有海外仓费用阶段 0A—0E 的设计或实现任务再读取阶段技术契约,并明确区分其目标 V2 协议与当前 V1 代码事实。 +- 普通代码任务从本文件快速查询和目标 Controller/Service 进入;产品范围不明时查 `../../docs/财务管理/README.md`,领域归属变化时查 `../../docs/财务管理/财务领域模型与跨服务契约.md`,Firstleg 费用对接变化时查 `../../docs/财务管理/Firstleg-Finance跨服务契约.md` 对应章节。产品确认表、五页验收契约和证据矩阵仅按具体规则或验收问题读取,不默认同时加载。海外仓费用阶段 0A—0E 任务才读取阶段技术契约,目标 V2 与当前实现沿代码分别核对。 - 海外仓费用阶段 0 按需契约:`../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`;仅在该阶段的实现、接口或验收任务中读取,不作为普通 Finance 任务首读。 - Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index e9bea312c..3279bbcd3 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -50,7 +50,7 @@ | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | -| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 14d35f248..8965757f4 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -1,10 +1,12 @@ # Overseas Gateway 模块指引 +> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。当前入口读 `../gateway/AGENTS.md`,认证读 `../auth/AGENTS.md` 与 `../../docs/统一认证与服务通信契约.md`;退役清理按 `../../docs/测试环境单机部署与配置契约.md` 核对实际引用与运行证据。不得恢复双 Token、专用安全投影或重新加入构建。 + ## 职责边界 - 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 - 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 -- 修改海外 Token、Audience、JWKS、区域路由或服务身份时,先读 `../../docs/跨区域认证与服务通信契约.md`。 +- 历史路由与身份实现只用于追溯;现行认证按 `../../docs/统一认证与服务通信契约.md`。 ## 快速索引 @@ -31,5 +33,5 @@ ## 修改与验证 -- 定向验证:`mvn -pl gateway-overseas -am test`。 +- 本模块已退出聚合,不能从根使用 `mvn -pl gateway-overseas`;当前路由变更验证 `gateway` 及调用端,执行根 `scripts/gateway-route-contract.test.mjs`。 - 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ba6124ce2..258d382be 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -30,9 +30,10 @@ | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | | 统一会话校验 | 复用 `../common-security/` Redis会话与Auth权威状态校验;Lastleg不维护授权投影 | | 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` | +| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` | +| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` | @@ -139,6 +140,6 @@ - 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 -- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 +- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 - 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 - 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index c3e71b849..6c9fd5e18 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -27,6 +27,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -406,6 +407,9 @@ public class OrderApplicationService { throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检"); } } + validateMixedCartons(order.getFulfillmentType(), cargoes.stream().map(cargo -> new MixedCarton( + cargo.getPackingGroupNo(), cargo.getPackageCount(), cargo.getPackageType(), cargo.getLengthCm(), + cargo.getWidthCm(), cargo.getHeightCm(), cargo.getGrossWeightKg(), cargo.getNetWeightKg(), cargo.getVolumeCbm())).toList()); var parties = repository.findParties(tenantId, order.getId()); if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) { throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息"); @@ -887,13 +891,21 @@ public class OrderApplicationService { return (int) total; } private void validateMixedCartons(OrderRequests.SaveRequest request) { - if (!"ONE_CLICK_DROPSHIP".equals(request.fulfillmentType())) return; - Map firstByGroup = new LinkedHashMap<>(); - for (OrderRequests.Cargo cargo : request.cargoes()) { + validateMixedCartons(request.fulfillmentType(), request.cargoes().stream().map(cargo -> new MixedCarton( + cargo.packingGroupNo(), cargo.packageCount(), cargo.packageType(), cargo.lengthCm(), + cargo.widthCm(), cargo.heightCm(), cargo.grossWeightKg(), cargo.netWeightKg(), cargo.volumeCbm())).toList()); + } + private record MixedCarton(String packingGroupNo, Integer packageCount, String packageType, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm) { } + private void validateMixedCartons(String fulfillmentType, List cargoes) { + if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) return; + Map firstByGroup = new LinkedHashMap<>(); + for (MixedCarton cargo : cargoes) { String group = normalize(cargo.packingGroupNo()); if (group == null) continue; - OrderRequests.Cargo first = firstByGroup.putIfAbsent(group, cargo); - if (first != null && (first.packageCount() != cargo.packageCount() + MixedCarton first = firstByGroup.putIfAbsent(group, cargo); + if (first != null && (!Objects.equals(first.packageCount(), cargo.packageCount()) || !Objects.equals(first.packageType(), cargo.packageType()) || !sameDecimal(first.lengthCm(), cargo.lengthCm()) || !sameDecimal(first.widthCm(), cargo.widthCm()) diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 1417e4afc..087c9615d 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -441,6 +441,85 @@ class OrderApplicationServiceStateTest { verify(repository, never()).insertHistory(any()); } + @Test + void submittingPersistedMixedCartonRejectsEveryPhysicalMismatchWithoutWritingForBothTaxModes() { + List> conflicts = List.of( + cargo -> cargo.setLengthCm(new BigDecimal("31")), + cargo -> { cargo.setPackageCount(3); cargo.setTotalQuantity(cargo.getUnitsPerPackage() * 3); }, + cargo -> cargo.setPackageType("PALLET"), + cargo -> cargo.setWidthCm(new BigDecimal("21")), + cargo -> cargo.setHeightCm(new BigDecimal("21")), + cargo -> cargo.setGrossWeightKg(new BigDecimal("21")), + cargo -> cargo.setNetWeightKg(new BigDecimal("19")), + cargo -> cargo.setVolumeCbm(new BigDecimal("0.025"))); + for (String taxMode : List.of("BUY_DECLARATION", "TAX_REFUND")) { + for (var conflict : conflicts) { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + order.setFulfillmentType("ONE_CLICK_DROPSHIP"); order.setTaxMode(taxMode); + OrderCargoEntity third = mixedCargo("SKU-3", 10); conflict.accept(third); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of( + mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third)); + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .submit(20002L, 2L, "u", 1L, "mixed-carton-conflict")) + .isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致") + .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); + assertThat(order.getSubmittedAt()).isNull(); assertThat(order.getInboundCode()).isNull(); + verify(repository, never()).saveOrder(anyLong(), any()); + verify(repository, never()).insertHistory(any()); verify(repository, never()).insertOutbox(any()); + verify(repository, never()).completeIdempotency(anyLong(), any(), anyLong()); + } + } + } + + @Test + void submittingConsistentMixedCartonAllowsDifferentSkuQuantitiesAndDecimalScalesAndCountsBoxesOnce() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + order.setFulfillmentType("ONE_CLICK_DROPSHIP"); + OrderCargoEntity third = mixedCargo("SKU-3", 12); + third.setPackingGroupNo(" GROUP-1 "); third.setLengthCm(new BigDecimal("30.00")); + third.setGrossWeightKg(new BigDecimal("20.0")); third.setVolumeCbm(new BigDecimal("0.0240")); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third)); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + when(repository.saveOrder(20002L, order)).thenReturn(1); + service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); + assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); + verify(repository).saveOrder(20002L, order); + verify(repository).insertOutbox(argThat(event -> event.getPayloadJson().contains("\"totalPackageCount\":2"))); + } + + @Test + void submittedMixedCartonEditorRejectsThirdSkuLengthConflictBeforeReplacingCargo() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + var request = mixedCartonRequest(31); + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .updateSubmittedOrder(10001L, 2L, 1L, request)) + .isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致") + .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).saveOrder(anyLong(), any()); + verify(repository, never()).replaceDraftChildren(anyLong(), anyLong()); + verify(repository, never()).insertHistory(any()); verify(repository, never()).insertOutbox(any()); + } + + @Test + void mixedCartonDraftStillAllowsIncompletePhysicalGroupBeforeSubmission() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + var request = mixedCartonRequest(31); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + service(repository, mock(OrderEligibilityGuard.class), masterData) + .createDraft(10001L, 2L, request, "mixed-carton-draft"); + verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT)); + } + @Test void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() { OrderRepository repository = mock(OrderRepository.class); @@ -1013,6 +1092,39 @@ class OrderApplicationServiceStateTest { return cargo; } + private OrderCargoEntity mixedCargo(String skuNo, int unitsPerPackage) { + OrderCargoEntity cargo = completeCargo(); + cargo.setSkuNo(skuNo); cargo.setPackingGroupNo("GROUP-1"); cargo.setPackageCount(2); + cargo.setUnitsPerPackage(unitsPerPackage); cargo.setTotalQuantity(unitsPerPackage * 2); + cargo.setLengthCm(new BigDecimal("30")); cargo.setWidthCm(new BigDecimal("20")); cargo.setHeightCm(new BigDecimal("20")); + cargo.setGrossWeightKg(new BigDecimal("20")); cargo.setNetWeightKg(new BigDecimal("18")); + cargo.setVolumeCbm(new BigDecimal("0.024")); + return cargo; + } + + private OrderRequests.SaveRequest mixedCartonRequest(int thirdLength) { + OrderRequests.SaveRequest source = request(); + var mapper = new ObjectMapper(); + var cargoes = java.util.stream.IntStream.rangeClosed(1, 3).mapToObj(index -> { + var json = mapper.valueToTree(source.cargoes().get(0)); + var cargo = (com.fasterxml.jackson.databind.node.ObjectNode) json; + cargo.put("skuNo", "SKU-" + index); cargo.put("packingGroupNo", "GROUP-1"); cargo.put("packageCount", 2); + cargo.put("unitsPerPackage", index == 2 ? 11 : 10); cargo.put("totalQuantity", index == 2 ? 22 : 20); + cargo.put("lengthCm", index == 3 ? thirdLength : 30); cargo.put("widthCm", 20); cargo.put("heightCm", 20); + cargo.put("grossWeightKg", 20); cargo.put("netWeightKg", 18); cargo.put("volumeCbm", new BigDecimal("0.024")); + return mapper.convertValue(cargo, OrderRequests.Cargo.class); + }).toList(); + return new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), cargoes, + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP"); + } + private OrderEntity persistedOrder(OrderStatus status) { OrderEntity order = new OrderEntity(); order.setId(1L); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index a68a7ae97..65182cb7f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,7 +4,13 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 -`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数;缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。 +`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 + +`AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原取消请求重放须 `A0409`,且根子版本、历史、摘要、履约树及完成 Outbox 零漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。 + +会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。 + +配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。 单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs new file mode 100644 index 000000000..67518ec7e --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { sharedPalletFreezeEvidence } from './inbound-measurement.mjs' + +function fixture() { + const sources = ['A', 'B'].map(inboundId => ({ inboundId, boxCodes: [`${inboundId}#1-1`], + skuLinesByBox: { [`${inboundId}#1-1`]: [{ inboundDetailId: `${inboundId}1`, skuCode: 'SHARED', quantity: 2 }] } })) + const pallet = { palletId: 'P1', palletNo: 'DP-260930-0001', status: 'SEALED', + sealedAt: '2026-09-30T16:00:00', version: 3, cartons: sources.map(source => ({ + boxCode: source.boxCodes[0], inboundId: source.inboundId, palletId: 'P1', palletNo: 'DP-260930-0001', + skuLines: [{ inboundDetailId: `${source.inboundId}1`, skuCode: 'SHARED', expectedQuantity: 2, actualQuantity: 2 }], + })) } + return { sources, tasks: [0, 1].map(() => ({ taskStatus: 'COMPLETED', pallets: [structuredClone(pallet)] })) } +} + +test('shared pallet compares both complete task projections to independently frozen per-box SKU sources', () => { + const { tasks, sources } = fixture() + assert.equal(sharedPalletFreezeEvidence(tasks, sources).passed, true) + tasks[1].pallets[0].cartons.reverse() + assert.equal(sharedPalletFreezeEvidence(tasks, sources).passed, true) +}) + +for (const [name, mutate] of [ + ['wrong owner despite matching aggregate count', task => { task.pallets[0].cartons[1].inboundId = 'A' }], + ['duplicate box despite matching aggregate count', task => { task.pallets[0].cartons[1] = structuredClone(task.pallets[0].cartons[0]) }], + ['SKU quantity drift', task => { task.pallets[0].cartons[0].skuLines[0].actualQuantity = 1 }], + ['wrong SKU detail ownership', task => { task.pallets[0].cartons[0].skuLines[0].inboundDetailId = 'B1' }], + ['box bound to another pallet', task => { task.pallets[0].cartons[0].palletId = 'P2' }], + ['missing seal fact', task => { delete task.pallets[0].sealedAt }], + ['incomplete carton snapshot', task => { task.pallets[0].cartons.pop() }], +]) { + test(`shared pallet rejects ${name}`, () => { + const { tasks, sources } = fixture() + mutate(tasks[1]) + assert.equal(sharedPalletFreezeEvidence(tasks, sources).passed, false) + }) +} diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 319527697..fbd696dd1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -34,6 +34,116 @@ export function measurementReplayDigest(facts) { return createHash('sha256').update(JSON.stringify(facts)).digest('hex') } +// Compare both task projections to independently frozen source identities, not +// to the first carton returned by a shared pallet. +export function sharedPalletFreezeEvidence(tasks, sources) { + const errors = [] + const expected = new Map() + for (const source of sources) { + for (const boxCode of source.boxCodes || []) { + if (expected.has(boxCode)) errors.push(`duplicate source box ${boxCode}`) + expected.set(boxCode, source) + } + } + if (sources.length < 2 || new Set(sources.map(source => asId(source.inboundId))).size !== sources.length) { + errors.push('requires distinct source inbounds') + } + const snapshots = tasks.map(task => { + if (task?.taskStatus !== 'COMPLETED' || task.pallets?.length !== 1) errors.push('task not completed on one pallet') + const pallet = task?.pallets?.[0] + if (!pallet?.palletId || !pallet.palletNo || pallet.status !== 'SEALED' + || !pallet.sealedAt || !Number.isInteger(Number(pallet.version))) errors.push('missing sealed pallet identity') + const seen = new Set() + const cartons = (pallet?.cartons || []).map(carton => { + const source = expected.get(carton.boxCode) + if (seen.has(carton.boxCode)) errors.push(`duplicate carton ${carton.boxCode}`) + seen.add(carton.boxCode) + if (!source || asId(carton.inboundId) !== asId(source.inboundId)) errors.push(`wrong owner ${carton.boxCode}`) + if (asId(carton.palletId) !== asId(pallet?.palletId) || carton.palletNo !== pallet?.palletNo) { + errors.push(`wrong pallet ${carton.boxCode}`) + } + const skuLines = (carton.skuLines || []).map(line => ({ + inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode, + expectedQuantity: Number(line.expectedQuantity), actualQuantity: Number(line.actualQuantity), + })).sort((left, right) => left.inboundDetailId.localeCompare(right.inboundDetailId)) + const expectedLines = (source?.skuLinesByBox?.[carton.boxCode] || []).map(line => ({ + inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode, + expectedQuantity: Number(line.quantity), actualQuantity: Number(line.quantity), + })).sort((left, right) => left.inboundDetailId.localeCompare(right.inboundDetailId)) + if (!expectedLines.length || JSON.stringify(skuLines) !== JSON.stringify(expectedLines)) { + errors.push(`sku source mismatch ${carton.boxCode}`) + } + return { boxCode: carton.boxCode, inboundId: asId(carton.inboundId), skuLines } + }).sort((left, right) => left.boxCode.localeCompare(right.boxCode)) + if (seen.size !== expected.size || [...expected.keys()].some(box => !seen.has(box))) errors.push('carton set incomplete') + return { palletId: asId(pallet?.palletId), palletNo: pallet?.palletNo, status: pallet?.status, + sealedAt: pallet?.sealedAt, version: Number(pallet?.version), cartons } + }) + if (tasks.length !== sources.length || snapshots.some(snapshot => JSON.stringify(snapshot) !== JSON.stringify(snapshots[0]))) { + errors.push('shared task freeze projections differ') + } + return { passed: errors.length === 0, errors, snapshot: snapshots[0] || null, + digest: measurementReplayDigest(snapshots[0] || null), sources } +} + +export async function verifySharedPalletFreeze(context, sources) { + const tasks = [] + for (const source of sources) { + const response = await context.request('GET', + `/api/platform/v1/first-leg/pda-dropship/tasks/${source.taskId}?warehouseId=${source.warehouseId}`) + tasks.push(context.apiSuccess(MODULE, '共托封托后逐票读取完整箱和SKU快照', response, + `inboundId=${source.inboundId}, taskId=${source.taskId}`, OPTIONS)) + } + const evidence = sharedPalletFreezeEvidence(tasks, sources) + context.assert(MODULE, '两票共托逐箱唯一归属及全部SKU冻结组成一致', null, + evidence.passed, JSON.stringify({ digest: evidence.digest, errors: evidence.errors, + palletId: evidence.snapshot?.palletId, cartonCount: evidence.snapshot?.cartons.length }), OPTIONS) + context.createdResources.sharedPalletFreeze = { marker: context.marker, ...evidence } + return evidence +} + +export async function prepareSharedPalletFreeze(context) { + const { run: createOrder } = await import('./orders.mjs') + const originalMarker = context.marker + const originalContext = { ...context.context } + const originalManualCount = context.options.prepareDropshipManualCount + const sources = [] + try { + for (const label of ['A', 'B']) { + context.marker = `${originalMarker}-PALLET-${label}` + for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] + context.context.orderDestinationType = 'OVERSEAS' + context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } + context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 2, packageCounts: [2], + totalPackageCount: 2, palletCount: 1, skuCodes: [`SKU-COMMON-${originalMarker}`.slice(0, 64)] } + context.options.prepareDropshipManualCount = true + const start = context.steps.length + await createOrder(context) + await run(context) + const source = context.createdResources.dropshipManualCount + if (!source?.taskId || context.steps.slice(start).some(step => !step.passed)) throw new Error('shared pallet preparation failed') + const task = await completeDropshipPalletization(context, { ...source, + measurement: { actualPackageCount: source.totalCartons, expectedPalletCount: 1 }, + ...(label === 'A' ? { deferSeal: true } : { targetPalletNo: sources[0].palletNo }), + }) + if (!task || context.steps.slice(start).some(step => !step.passed)) throw new Error('shared pallet count or seal failed') + sources.push({ taskId: asId(source.taskId), inboundId: asId(source.inboundId), + inboundCode: source.inboundCode, warehouseId: asId(source.warehouseId), + palletNo: task.pallets?.[0]?.palletNo, boxCodes: task.sealedCartonMarks, + skuLinesByBox: task.sealedSkuSources }) + context.createdResources.sharedPalletFreezePreparation = { marker: originalMarker, sources } + } + context.marker = originalMarker + return await verifySharedPalletFreeze(context, sources) + } finally { + context.marker = originalMarker + context.context = originalContext + context.options.prepareDropshipManualCount = originalManualCount + } +} + async function readMeasurementReplayFacts(context, { inboundId, tenantId, measurementId }, name) { const response = await context.request('GET', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) @@ -236,6 +346,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun let countedCartons = 0 const sealedCartonMarks = [] + const sealedSkuSources = {} for (let index = 0; index < totalCartons; index += 1) { const boxCode = `${inboundCode}#${totalCartons}-${index + 1}` @@ -244,6 +355,9 @@ export async function completeDropshipPalletization(context, { inboundId, inboun const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse, `boxCode=${boxCode}`, OPTIONS) const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : [] + sealedSkuSources[boxCode] = resolvedSkuLines.map(line => ({ + inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode, quantity: Number(line.expectedQuantity), + })) context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse, resolved?.canCount === true && resolvedSkuLines.length > 0 && resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode @@ -361,7 +475,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun if (deferSeal) { context.assert(MODULE, '多票合托首票归托后保持开放等待下一票', null, sealed?.taskStatus !== 'COMPLETED' && sealed?.pallets?.every((item) => item.status === 'OPEN'), '', OPTIONS) - return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [] } + return { ...sealed, sealedCartonMarks, sealedSkuSources, sealedSourcePalletNos: [] } } let lastSealResponse = countedTaskResponse for (let index = 0; index < (sealed?.pallets || []).length; index += 1) { @@ -383,7 +497,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun && Number(sealed?.unboundCartons) === 0 && sealed?.pallets?.every((item) => item?.status === 'SEALED'), `taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS) - return { ...sealed, sealedCartonMarks, + return { ...sealed, sealedCartonMarks, sealedSkuSources, sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) } } diff --git a/scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs b/scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs new file mode 100644 index 000000000..439381694 --- /dev/null +++ b/scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { cargoRequirementNegativeCases } from './orders.mjs' + +const payload = { destinationType: 'FBA', fulfillmentType: 'STANDARD', customsInspection: true, + shipmentId: 'SHIP-1', cargoes: [{ skuNo: 'SKU-1', packageCount: 2, unitsPerPackage: 3, + totalQuantity: 6, referenceId: 'TRACK-1', woodenProduct: false, lengthCm: 10, packingGroupNo: 'GROUP-1' }] } + +test('FBA negatives independently remove order identifiers and per-line identifiers without mutating source', () => { + const before = structuredClone(payload) + const cases = cargoRequirementNegativeCases(payload) + assert.equal(cases.length, 5) + assert.equal(cases.find(item => item.name.includes('订单货件')).body.shipmentId, null) + assert.equal(cases.find(item => item.name.includes('逐行跟踪')).body.cargoes[0].referenceId, null) + assert.deepEqual(payload, before) +}) + +test('overseas and private standard paths retain destination type and common declaration constraints', () => { + for (const destinationType of ['OVERSEAS', 'PRIVATE']) { + const cases = cargoRequirementNegativeCases({ ...payload, destinationType }) + assert.equal(cases.length, 3) + assert.ok(cases.every(item => item.body.destinationType === destinationType)) + assert.ok(cases.every(item => !item.name.startsWith('FBA'))) + } +}) + +test('dropship checks required SKU and inconsistent physical carton grouping', () => { + const cases = cargoRequirementNegativeCases({ ...payload, destinationType: 'OVERSEAS', fulfillmentType: 'ONE_CLICK_DROPSHIP' }) + assert.equal(cases.length, 5) + assert.equal(cases.find(item => item.name.includes('缺少SKU')).body.cargoes[0].skuNo, null) + const mixed = cases.find(item => item.name.includes('混箱')).body.cargoes + assert.equal(mixed[0].packingGroupNo, mixed[1].packingGroupNo) + assert.notEqual(mixed[0].lengthCm, mixed[1].lengthCm) +}) diff --git a/scripts/admin-test-env-modules/orders-contract.test.mjs b/scripts/admin-test-env-modules/orders-contract.test.mjs new file mode 100644 index 000000000..00f019527 --- /dev/null +++ b/scripts/admin-test-env-modules/orders-contract.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { finishOrderLifecycle } from './orders.mjs' + +const input = { tenantId: '2105193679178272769', orderId: '2105193704449441794', firstLegDetailPath: '/original-order-detail' } +function fixture(option, status, transitions) { + const calls = []; const checks = [] + const resources = { orders: { tenantId: input.tenantId, orderId: input.orderId, + inboundId: '2105193705748041729', inboundCode: 'TEST-APPROVED', skuLines: [{ skuNo: 'SKU-A' }] }, + ocrInbound: { tenantId: input.tenantId, inboundId: '2105193705748041729' } } + const context = { context: { orderStopAtApproved: option }, createdResources: resources, + async request(method, path) { calls.push({ method, path }); + return { http: 200, body: { code: '200', data: method === 'GET' + ? { order: { status }, statusHistory: transitions.map(toStatus => ({ toStatus })) } : {} } } }, + apiSuccess(_module, _step, response) { assert.equal(response.body.code, '200'); return response.body.data }, + assert(_module, step, _response, valid) { checks.push({ step, valid: Boolean(valid) }) }, + } + return { context, calls, checks, resources } +} + +test('explicit approved stop checks raw Order and never sends accept or start; measurement resources survive', async () => { + const f = fixture(true, 'APPROVED', ['DRAFT', 'PENDING_REVIEW', 'APPROVED']) + const before = JSON.stringify(f.resources) + await finishOrderLifecycle(f.context, input) + assert.deepEqual(f.calls, [{ method: 'GET', path: input.firstLegDetailPath }]) + assert.ok(f.checks.every(check => check.valid)) + assert.equal(f.resources.orders.rawOrderStatus, 'APPROVED') + assert.equal(f.resources.orders.stoppedAtApproved, true) + delete f.resources.orders.rawOrderStatus; delete f.resources.orders.stoppedAtApproved + assert.equal(JSON.stringify(f.resources), before) +}) + +for (const option of [undefined, false, 'true']) { + test(`only boolean true opts out of default accept/start: ${String(option)}`, async () => { + const f = fixture(option, 'PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCESSING']) + await finishOrderLifecycle(f.context, input) + assert.equal(f.calls.length, 3) + assert.ok(f.calls[0].path.includes('/accept?')) + assert.ok(f.calls[1].path.includes('/start?')) + assert.deepEqual(f.calls.map(call => call.method), ['POST', 'POST', 'GET']) + assert.ok(f.checks.every(check => check.valid)) + assert.equal(f.resources.orders.rawOrderStatus, 'PROCESSING') + }) +} + +for (const [status, history] of [['PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCESSING']], + ['APPROVED', ['APPROVED', 'ACCEPTED']], ['APPROVED', ['PENDING_REVIEW']]]) { + test(`approved stop rejects invalid raw status/history: ${status}/${history.join(',')}`, async () => { + const f = fixture(true, status, history) + await assert.rejects(finishOrderLifecycle(f.context, input), /原始APPROVED/) + assert.equal(f.calls.length, 1) + assert.ok(f.checks.some(check => !check.valid)) + }) +} + +test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => { + const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') + const run = source.slice(source.indexOf('export async function run(')) + const finish = run.indexOf('await finishOrderLifecycle(') + for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确', + 'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) { + assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment) + } +}) diff --git a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs index 5198741cb..43a4ae583 100644 --- a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs @@ -18,6 +18,17 @@ test('orders fixture verifies the complete pricing tuple before selecting an FBA assert.match(source, /warehouse\?\.status === 'ACTIVE'/) }) +test('private addresses select an authoritative channel without requiring a warehouse destination rate row', async () => { + const result = await findPricedOrderRouteAcrossOrigins({ + receivings: [{ id: 'origin' }], destinations: [], destinationType: 'PRIVATE', + loadChannels: async () => ({ http: 200, body: { code: '200', data: [{ channelCode: 'C', pricingTaxModes: ['TAX_INCLUDED'] }] } }), + estimate: async () => { throw new Error('private address is not a warehouse destination') }, + }) + assert.equal(result.priced.channel.channelCode, 'C') + assert.equal(result.priced.destination, null) + assert.equal(result.attempts, 0) +}) + test('FBA order fixture excludes warehouses without the internal code required by pallet labels', () => { const warehouses = [ { id: 'abe3', warehouseCode: 'ABE3', status: 'ACTIVE', internalCode: null }, diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a48979c7e..823c6a3f6 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -42,6 +42,61 @@ export function fixtureInboundCode(policy, marker) { // Short enough for the 34-character carton QR limit; stable within this fresh fixture. return { inboundCode: `AT${createHash('sha256').update(marker).digest('hex').slice(0, 20).toUpperCase()}` } } + +export function cargoRequirementNegativeCases(payload) { + const cases = [ + { name: '木制品未选择商检', at: 'CREATE', mutate: value => { value.customsInspection = false; value.cargoes[0].woodenProduct = true } }, + { name: '货物零箱数', at: 'CREATE', mutate: value => { value.cargoes[0].packageCount = 0 } }, + { name: '货物总件数与每箱件数不守恒', at: 'CREATE', mutate: value => { value.cargoes[0].totalQuantity++ } }, + ] + if (payload.destinationType === 'FBA') { + cases.push({ name: 'FBA缺少订单货件编号', at: 'SUBMIT', mutate: value => { value.shipmentId = null } }, + { name: 'FBA缺少逐行跟踪编号', at: 'SUBMIT', mutate: value => { value.cargoes[0].referenceId = null } }) + } + if (payload.fulfillmentType === 'ONE_CLICK_DROPSHIP') { + cases.push({ name: '一件代发缺少SKU', at: 'CREATE_OR_SUBMIT', mutate: value => { value.cargoes[0].skuNo = null } }, + { name: '一件代发混箱组箱规冲突', at: 'SUBMIT', mutate: value => { + const extra = structuredClone(value.cargoes[0]); extra.skuNo = `${extra.skuNo}-B`.slice(0, 64); extra.lengthCm++ + value.cargoes.push(extra) + } }) + } + return cases.map(({ mutate, ...item }) => { const body = structuredClone(payload); mutate(body); return { ...item, body } }) +} + +async function verifyCargoRequirementNegatives(context, payload, policy, merchantToken) { + const base = '/api/merchant/v1/orders/first-leg' + const cases = cargoRequirementNegativeCases(payload) + const evidence = [] + for (const [index, item] of cases.entries()) { + const body = { ...item.body, ...fixtureInboundCode(policy, `${context.marker}-NEG-${index}`) } + const created = await request(context, 'POST', base, body, merchantToken, + { 'Idempotency-Key': key(context, `cargo-negative-${index}-create`) }) + if (item.at === 'CREATE' || String(created?.body?.code) !== '200') { + context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}创建拒绝`, created, + created.http === 200 && String(created.body?.code) === 'A0422', + `HTTP=${created.http}, code=${created.body?.code || 'missing'}`, CONTRACT_OPTIONS) + evidence.push({ name: item.name, stage: 'CREATE', code: created.body?.code, + orderId: String(created.body?.code) === '200' ? id(created.body?.data) : null }) + continue + } + const orderId = id(created.body?.data) + if (!orderId) throw new Error('negative draft missing order id') + const before = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken) + const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, undefined, merchantToken, + { 'Idempotency-Key': key(context, `cargo-negative-${index}-submit`) }) + const after = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken) + context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}提交拒绝且草稿零漂移`, submitted, + submitted.http === 200 && String(submitted.body?.code) === 'A0422' + && String(before.body?.code) === '200' && String(after.body?.code) === '200' + && after.body.data?.order?.status === 'DRAFT' + && JSON.stringify(before.body.data) === JSON.stringify(after.body.data), + `orderId=${orderId}, code=${submitted.body?.code || 'missing'}`, CONTRACT_OPTIONS) + evidence.push({ name: item.name, stage: 'SUBMIT', code: submitted.body?.code, orderId }) + } + context.createdResources.orderCargoNegatives ??= [] + context.createdResources.orderCargoNegatives.push({ marker: context.marker, + destinationType: payload.destinationType, fulfillmentType: payload.fulfillmentType, cases: evidence }) +} function declarationCargo(source, index) { const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0 const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value) @@ -152,6 +207,33 @@ export async function verifyOrderApprovalReplay(context, { tenantId, orderId, in context.createdResources.orders.approvalReplay = evidence return evidence } + +export async function finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath }) { + const stopAtApproved = context.context?.orderStopAtApproved === true + if (!stopAtApproved) { + const acceptResponse = await context.request('POST', + `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse) + const startResponse = await context.request('POST', + `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`) + context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse) + } + const finalDetailResponse = await context.request('GET', firstLegDetailPath) + const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse) + const history = finalDetail?.statusHistory + const valid = finalDetail?.order?.status === (stopAtApproved ? 'APPROVED' : 'PROCESSING') + && history?.some?.((item) => item.toStatus === 'APPROVED') + && (stopAtApproved + ? !history.some((item) => ['ACCEPTED', 'PROCESSING'].includes(item.toStatus)) + : history.some((item) => item.toStatus === 'ACCEPTED') + && history.some((item) => item.toStatus === 'PROCESSING')) + context.assert(MODULE, stopAtApproved ? '显式审批停点保留原始APPROVED且未受理启动' : '本轮订单已进入处理中且保留状态历史', + finalDetailResponse, valid, + `status=${finalDetail?.order?.status ?? 'missing'}, history=${history?.length ?? 'n/a'}`, CONTRACT_OPTIONS) + if (stopAtApproved && !valid) throw new Error('显式审批停点未保留原始APPROVED,不能继续准备该专项') + context.createdResources.orders.rawOrderStatus = finalDetail?.order?.status || null + context.createdResources.orders.stoppedAtApproved = stopAtApproved +} function tomorrow(days = 30) { const date = new Date(); date.setUTCDate(date.getUTCDate() + days) return date.toISOString().slice(0, 10) @@ -239,6 +321,14 @@ export async function findPricedOrderRouteAcrossOrigins({ receivings, destinatio traceId: channelResponse?.traceId ?? 'missing', count: channels.length }) continue } + if (destinationType === 'PRIVATE') { + // Private destinations use customer-owned addresses, not warehouse-rate + // destination rows. Match the same authoritative channel path already + // used by fixed-origin private orders. + const channel = priceableChannels[0] + return { receiving, priced: { channel, pricingTaxMode: channel.pricingTaxModes[0], destination: null }, + response: channelResponse, attempts: 0, failures: [], originFailures } + } const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, estimate, concurrency, destinationType }) attempts += result.attempts @@ -439,7 +529,9 @@ async function loadMasterData(context, merchantToken) { } const priced = pricingResult.priced const receiving = pricingResult.receiving - context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced), + context.addStep(MODULE, destinationType === 'PRIVATE' + ? 'Client 私人地址订单选择有效国内仓渠道与报价税制' + : 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced), priced ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}` : originPricingFailureEvidence(pricingResult), @@ -559,7 +651,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr shipmentId: master.destinationType === 'FBA' ? shipmentId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null, upcNo: `${Date.now()}${index}`.slice(-12), - productImageFileId, dangerousGoods: false, woodenProduct: false, + productImageFileId, dangerousGoods: orderProfile.dangerousGoods === true, + woodenProduct: orderProfile.woodenProduct === true, cargoRemark: `${context.marker} boxType=${box.boxType}`, } }), @@ -673,6 +766,9 @@ export async function run(context) { const policy = context.apiSuccess(MODULE, 'Client 读取入仓码录入策略', policyResponse, '', CONTRACT_OPTIONS) const payload = { ...buildPayload(context, profile, master, productImageFileId, customerAddresses), ...fixtureInboundCode(policy, context.marker) } + if (context.context.verifyOrderCargoNegatives === true) { + await verifyCargoRequirementNegatives(context, payload, policy, merchantToken) + } const createResponse = await request(context, 'POST', createPath, payload, merchantToken, { 'Idempotency-Key': key(context, 'client-create') }) const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse, @@ -910,19 +1006,5 @@ export async function run(context) { `taskId=${task.id}, total=${history?.total ?? 'n/a'}`) } - const acceptResponse = await context.request('POST', - `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`) - context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse) - const startResponse = await context.request('POST', - `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`) - context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse) - - const finalDetailResponse = await context.request('GET', firstLegDetailPath) - const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse) - context.assert(MODULE, '本轮订单已进入处理中且保留状态历史', finalDetailResponse, - finalDetail?.order?.status === 'PROCESSING' - && finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'APPROVED') - && finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'ACCEPTED') - && finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'PROCESSING'), - `status=${finalDetail?.order?.status ?? 'missing'}, history=${finalDetail?.statusHistory?.length ?? 'n/a'}`) + await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath }) } diff --git a/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs b/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs new file mode 100644 index 000000000..587f1e20e --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { customsRetentionDigest, verifyTaxCustomsAfterShipEdit } from './phase3-port-schedules.mjs' + +test('customs retention digest includes documents, receipt versions, confirmations and completion facts', () => { + const before = { status: 'COMPLETED', documents: [{ fileId: '1', tenantId: 'A', version: 2 }], + receipts: [{ fileId: '2', version: 3 }], confirmations: [{ tenantId: 'A', status: 'CONFIRMED' }], + completionFiles: [{ fileId: '3' }] } + assert.equal(customsRetentionDigest(before), customsRetentionDigest({ ...before })) + for (const mutate of [ + value => { value.documents[0].tenantId = 'B' }, + value => { value.receipts[0].version++ }, + value => { value.confirmations[0].status = 'RETURNED' }, + value => { value.completionFiles = [] }, + ]) { + const after = structuredClone(before) + mutate(after) + assert.notEqual(customsRetentionDigest(before), customsRetentionDigest(after)) + } +}) + +test('object key ordering does not obscure stable customs facts', () => { + assert.equal(customsRetentionDigest({ a: 1, b: { x: 2, y: 3 } }), + customsRetentionDigest({ b: { y: 3, x: 2 }, a: 1 })) +}) + +test('ship edit keeps original completed customs, query and node facts and sends only formal DTO fields', async () => { + const marker = 'F0124-SHIP-TEST' + const customs = { loadingPlanId: 'plan', containerBookingId: 'container', status: 'COMPLETED', taxRefundCount: 1, + documents: [{ source: { fileId: 'source' }, receipt: { fileId: 'receipt', version: 2 }, + receiptStatus: 'CONFIRMED', confirmationHistory: [{ id: 'confirmation' }], finalDocument: { fileId: 'final' } }] } + const schedule = { id: 'schedule', version: 2, customerId: 'customer', oceanBookingId: 'ocean', + containerBookingId: 'container', vesselName: 'old', voyageNo: 'old', sourceType: 'MANUAL', + sourceName: 'source', sourceRef: 'ref', observedAt: '2026-09-30T12:00:00', expiresAt: '2026-10-01T12:00:00' } + const query = { id: 'query', containerBookingId: 'container', oceanBookingId: 'ocean', + trackingNo: 'BOX', latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-09-30T12:00:00' } + const nodes = [{ id: 'original-node', standardNode: 'DEPARTED_WAREHOUSE', rawPayloadHash: 'original-hash' }] + let edited + const steps = [] + const context = { marker, token: 'offline-placeholder', options: { gateway: 'https://offline.invalid' }, createdResources: {}, + apiSuccess(_module, name, response) { steps.push({ name, passed: response.http === 200 }); return response.body.data }, + assert(_module, name, _response, passed) { steps.push({ name, passed }) } } + const originalFetch = globalThis.fetch + globalThis.fetch = async (url, options) => { + const path = new URL(url).pathname + let data + if (options.method === 'PUT') { + edited = JSON.parse(options.body) + data = {} + } else if (path.includes('/customs/')) data = structuredClone(customs) + else if (path.endsWith('/carrier-schedule-queries/query')) data = { query: { query }, nodes } + else if (path.endsWith('/carrier-schedule-queries')) data = { total: 1, records: [{ query }] } + else data = { portSchedule: { schedule: edited ? { ...schedule, ...edited.data, version: 3 } : schedule }, + histories: edited ? [{ id: 'history', action: 'UPDATE', operatorId: 'operator', createdAt: '2026-09-30T13:00:00', toVersion: 3 }] : [] } + return new Response(JSON.stringify({ code: '200', data }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + try { + await verifyTaxCustomsAfterShipEdit(context, { marker, loadingPlanId: 'plan', containerBookingId: 'container', queryId: 'query', scheduleId: 'schedule' }) + assert.equal(steps.every(step => step.passed), true, JSON.stringify(steps)) + assert.equal(edited.expectedVersion, 2) + assert.equal('id' in edited.data, false) + assert.equal('version' in edited.data, false) + assert.equal(edited.data.customerId, 'customer') + assert.equal(context.createdResources.taxCustomsShipRetention.customsBeforeDigest, + context.createdResources.taxCustomsShipRetention.customsAfterDigest) + } finally { globalThis.fetch = originalFetch } +}) + +test('ship edit refuses incomplete tax customs before any PUT', async () => { + const calls = [] + const originalFetch = globalThis.fetch + globalThis.fetch = async (_url, options) => { + calls.push(options.method) + return new Response(JSON.stringify({ code: '200', data: { status: 'DECLARING', taxRefundCount: 1 } }), { status: 200 }) + } + const context = { marker: 'own', token: '', options: { gateway: 'https://offline.invalid' }, + apiSuccess(_module, _name, response) { return response.body.data }, assert() {} } + try { + await assert.rejects(verifyTaxCustomsAfterShipEdit(context, + { marker: 'own', loadingPlanId: 'plan', containerBookingId: 'container', queryId: 'query', scheduleId: 'schedule' }), /completed tax customs/) + assert.deepEqual(calls, ['GET']) + } finally { globalThis.fetch = originalFetch } +}) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index bd1171bb1..a7ef61927 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -6,6 +6,81 @@ const BASE = '/api/platform/v1/port-schedules' const OCEAN = '/api/platform/v1/ocean-bookings' const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches' import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs' +import { createHash } from 'node:crypto' + +export function customsRetentionDigest(value) { + const canonical = input => Array.isArray(input) ? input.map(canonical) + : input && typeof input === 'object' ? Object.fromEntries(Object.keys(input).sort().map(key => [key, canonical(input[key])])) + : input + return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex') +} + +export async function verifyTaxCustomsAfterShipEdit(context, fixture) { + if (fixture?.marker !== context.marker || !fixture?.loadingPlanId || !fixture?.queryId || !fixture?.scheduleId) { + throw new Error('requires current marker owned tax customs, query and schedule fixture') + } + const customsPath = `/api/platform/v1/customs/loading-plans/${fixture.loadingPlanId}` + const queryPath = `/api/platform/v1/carrier-schedule-queries/${fixture.queryId}` + async function read(path, name) { + const response = await request(context, 'GET', path) + const value = context.apiSuccess(MODULE, name, response, '', OPTIONS) + if (!value) throw new Error(`${name} failed`) + return value + } + const beforeCustoms = await read(customsPath, '改船前冻结同一退税柜完整资料回执确认事实') + const completedTax = beforeCustoms.status === 'COMPLETED' && Number(beforeCustoms.taxRefundCount) > 0 + && id(beforeCustoms.loadingPlanId) === id(fixture.loadingPlanId) + && id(beforeCustoms.containerBookingId) === id(fixture.containerBookingId) + && beforeCustoms.documents?.length > 0 + && beforeCustoms.documents.every(document => document.source?.fileId && document.receipt?.fileId + && document.receiptStatus === 'CONFIRMED' && document.finalDocument?.fileId) + context.assert(MODULE, '改船样本为本轮已完成且含资料回执确认完成文件的退税柜', null, + completedTax, `loadingPlanId=${fixture.loadingPlanId}, taxRefundCount=${beforeCustoms.taxRefundCount}`, OPTIONS) + if (!completedTax) throw new Error('requires completed tax customs evidence') + const beforeQuery = await read(queryPath, '改船前冻结原柜唯一船任务与正常节点') + const beforeSchedule = await read(`${BASE}/${fixture.scheduleId}`, '改船前读取原柜船期') + const schedule = beforeSchedule.portSchedule?.schedule + const beforeTask = beforeQuery.query?.query + if (!schedule || id(beforeTask?.id) !== id(fixture.queryId) || id(schedule.containerBookingId) !== id(fixture.containerBookingId) + || id(beforeTask?.containerBookingId) !== id(fixture.containerBookingId)) throw new Error('ship fixture identity mismatch') + const editedData = { ...Object.fromEntries(['customerId', 'oceanBookingId', 'containerBookingId', + 'sourceType', 'sourceName', 'sourceRef', 'departurePortCode', 'departurePortName', + 'destinationPortCode', 'destinationPortName', 'etdAt', 'etaAt', 'bookingCutoffAt', + 'customsCutoffAt', 'portCutoffAt', 'observedAt', 'expiresAt', 'remark'] + .map(key => [key, schedule[key]])), vesselName: `人工改船-${context.marker}`.slice(0, 128), + voyageNo: `R-${context.marker}`.slice(0, 64) } + const response = await request(context, 'PUT', `${BASE}/${fixture.scheduleId}`, + { expectedVersion: Number(schedule.version), data: editedData, reason: `${context.marker} 原柜人工改船` }, + { 'Idempotency-Key': `${context.marker}-tax-ship-edit`.slice(0, 128) }) + const edited = context.apiSuccess(MODULE, '退税报关完成后在原柜人工改船', response, '', OPTIONS) + if (!edited) throw new Error('ship edit failed') + const afterCustoms = await read(customsPath, '改船后读取同一退税柜完整资料回执确认事实') + const afterQuery = await read(queryPath, '改船后回查原唯一船任务与正常节点') + const afterSchedule = await read(`${BASE}/${fixture.scheduleId}`, '改船后读取修改历史') + const selector = new URLSearchParams({ containerBookingId: fixture.containerBookingId, page: '1', size: '200' }) + const page = await read(`/api/platform/v1/carrier-schedule-queries?${selector}`, '改船后按原柜核对船任务唯一') + const customsBeforeDigest = customsRetentionDigest(beforeCustoms) + const customsAfterDigest = customsRetentionDigest(afterCustoms) + context.assert(MODULE, '原柜改船前后报关资料回执确认完成文件完整摘要零漂移', null, + customsBeforeDigest === customsAfterDigest, `before=${customsBeforeDigest}, after=${customsAfterDigest}`, OPTIONS) + const sameTask = ['id', 'containerBookingId', 'oceanBookingId', 'trackingNo', 'latestActualNode', 'latestActualAt'] + .every(key => id(beforeTask[key]) === id(afterQuery.query?.query?.[key])) + context.assert(MODULE, '原柜改船保留唯一查询任务和全部已记录正常节点', null, + sameTask && customsRetentionDigest(beforeQuery.nodes) === customsRetentionDigest(afterQuery.nodes) + && Number(page.total) === 1 && id(page.records?.[0]?.query?.id) === id(fixture.queryId), + `queryId=${fixture.queryId}, total=${page.total}`, OPTIONS) + const latest = afterSchedule.portSchedule?.schedule + const update = afterSchedule.histories?.at(-1) + context.assert(MODULE, '人工改船保留原柜订舱及操作人原因版本历史', null, + id(latest?.id) === id(schedule.id) && id(latest?.containerBookingId) === id(schedule.containerBookingId) + && id(latest?.oceanBookingId) === id(schedule.oceanBookingId) + && latest?.vesselName === editedData.vesselName && latest?.voyageNo === editedData.voyageNo + && update?.action === 'UPDATE' && Boolean(update.operatorId && update.createdAt) + && Number(update.toVersion) === Number(schedule.version) + 1, + `scheduleId=${fixture.scheduleId}, historyId=${update?.id || 'missing'}`, OPTIONS) + context.createdResources.taxCustomsShipRetention = { fixture, beforeCustoms, afterCustoms, + customsBeforeDigest, customsAfterDigest, beforeQuery, afterQuery, beforeSchedule, afterSchedule } +} function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 6bce91243..a70e5bbdf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -5,7 +5,137 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer, - crossContainerCartonProbe } from './phase4-overseas-pda.mjs' + crossContainerCartonProbe, normalizeReceivingEvidence, receivingEvidenceValid, + receivingCompletionProof, captureReceivingEvidence, probeCartonResolveNegatives } from './phase4-overseas-pda.mjs' + +test('single-container continuation retains malformed/unknown probes without requiring unshipped cancelled CHILD; default chain still blocks missing real other container', async () => { + const requests = [], gaps = [], checks = [] + const context = { marker: 'ONE-CANCEL', createdResources: {}, + request: async (_method, _path, body) => { requests.push(body.cartonMark); return { http: 200, body: { code: 'A0404', message: 'Carton mark was not found' }, requestId: 'request', traceId: 'trace' } }, + addStep: (_module, _name, _response, passed) => checks.push(passed), + addCoverageGap: (...args) => gaps.push(args), assert: () => {} } + const current = { containerNo: 'REAL', inbounds: [{ inboundCode: 'ONE', destinationType: 'FBA' }] } + await probeCartonResolveNegatives(context, 'token', current, false) + assert.equal(requests.length, 3) + assert.ok(checks.every(Boolean)) + assert.equal(gaps.length, 0) + await probeCartonResolveNegatives(context, 'token', current) + assert.equal(gaps.length, 1) + assert.match(gaps[0][3], /另一真实柜/) + assert.match(source, /probeCartonResolveNegatives\(context, token, current.data, runAuthorizationProbes\)/) +}) + +function receivingProofFixture() { + const scope = { customerId: '1', warehouseId: '2', bookingId: '3', containerNo: 'REAL', + inboundCodes: ['ONE', 'TWO'], sourceInboundIds: ['101', '102'], expectedPackagesByInbound: { ONE: 2, TWO: 2 }, + expectedTransitPackages: 4, completeKey: 'REAL-COMPLETE', freshKey: 'REAL-FRESH' } + const buckets = { availablePackages: 0, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 } + const balance = (bid, available, transit) => ({ id: bid, customerId: '1', overseasWarehouseId: '2', + destinationWarehouseId: '4', destinationWarehouseCode: 'FBA', inboundOrderId: bid === '10' ? '101' : '102', + inboundCode: bid === '10' ? 'ONE' : 'TWO', skuCode: 'SAME-SKU', ...buckets, availablePackages: available, inTransitPackages: transit, procurementPackages: 0 }) + const position = (pid, bid) => ({ id: pid, balanceId: bid, customerId: '1', overseasWarehouseId: '2', + destinationWarehouseCode: 'FBA', palletId: '70', palletNo: 'DP-REAL', batchNo: 'BATCH', + warehouseLocationId: '80', warehouseLocationCode: 'LOC', status: 'AVAILABLE', ...buckets, availablePackages: 2 }) + const receipt = (rid, bid, pid, code, tail, task) => ({ id: rid, customerId: '1', planId: '90', taskId: task, + allocationId: '91', lineId: '92', orderId: tail, inboundCode: code, skuCode: 'SAME-SKU', + warehousePalletId: '70', palletNo: 'DP-REAL', warehouseLocationId: '80', warehouseLocationCode: 'LOC', + batchNo: 'BATCH', receivedPackages: 2, inventoryBalanceId: bid, inventoryPositionId: pid, + inventoryOperationKey: `PDA_STOCK_IN:${task}:91:SAME-SKU` }) + const receipts = [receipt('1001', '10', '201', 'ONE', '101', '301'), receipt('1002', '20', '202', 'TWO', '102', '302')] + const operations = receipts.map((row, index) => ({ id: String(400 + index), customerId: '1', + idempotencyKey: row.inventoryOperationKey, operationType: 'STOCK_IN', requestHash: 'a'.repeat(64), status: 'COMPLETED' })) + const ledgers = receipts.map((row, index) => ({ id: String(500 + index), customerId: '1', balanceId: row.inventoryBalanceId, + positionId: row.inventoryPositionId, overseasWarehouseId: '2', inboundOrderId: row.orderId, inboundCode: row.inboundCode, + palletId: '70', palletNo: 'DP-REAL', batchNo: 'BATCH', skuCode: 'SAME-SKU', businessType: 'STOCK_IN', businessOrderNo: 'TAIL-REAL', + sourceEventId: 'EVENT-REAL', idempotencyKey: `${row.inventoryOperationKey}:${operations[index].id}`, + statusFrom: 'IN_TRANSIT', statusTo: 'AVAILABLE', beforePackages: 0, deltaPackages: 2, afterPackages: 2 })) + const before = { balances: [balance('10', 0, 2), balance('20', 0, 2)], positions: [], palletComposition: [], + stockReceipts: [], inventoryOperations: [], stockInLedger: [], receivingOperations: [], + httpGroups: [balance('10', 0, 4)], httpLedger: [] } + const after = { balances: [balance('10', 2, 0), balance('20', 2, 0)], positions: [position('201', '10'), position('202', '20')], + palletComposition: [position('201', '10'), position('202', '20')], stockReceipts: receipts, inventoryOperations: operations, stockInLedger: ledgers, + receivingOperations: [{ id: '601', sessionId: '701', action: 'COMPLETE', idempotencyKey: 'REAL-COMPLETE', requestHash: 'b'.repeat(64) }], + httpGroups: [balance('10', 4, 0)], httpLedger: ledgers } + const stages = { BEFORE_COMPLETE: normalizeReceivingEvidence(before), AFTER_COMPLETE: normalizeReceivingEvidence(after), + AFTER_SAME_KEY_REPLAY: normalizeReceivingEvidence(after), AFTER_FRESH_KEY_REJECTION: normalizeReceivingEvidence(after) } + return { scope, before, after, stages } +} + +test('receiving completion freezes all raw balances when group MIN represents two tickets on one pallet', () => { + const { scope, stages } = receivingProofFixture() + assert.equal(receivingEvidenceValid(stages.BEFORE_COMPLETE, scope), true) + assert.equal(receivingEvidenceValid(stages.AFTER_COMPLETE, scope), true) + const proof = receivingCompletionProof(scope, stages) + assert.equal(proof.passed, true, JSON.stringify(proof)) + assert.equal(stages.AFTER_COMPLETE.balances.length, 2) + assert.equal(stages.AFTER_COMPLETE.httpGroups.length, 1) + assert.equal(stages.AFTER_COMPLETE.httpGroups[0].availablePackages, 4) + assert.equal(stages.AFTER_COMPLETE.positions.length, 2) +}) + +test('receiving proof rejects missing raw source, wrong customer/warehouse, prefix source, missing bucket, ledger key, duplicate receipt and operation mismatch', () => { + const mutations = [ + row => row.balances.pop(), row => row.positions[0].customerId = 'OTHER', + row => row.balances[0].overseasWarehouseId = 'OTHER', row => row.stockReceipts[0].inboundCode = 'ONE-CHILD', + row => row.balances[0].holdPackages = null, row => row.stockInLedger[0].idempotencyKey = row.inventoryOperations[0].idempotencyKey, + row => row.stockReceipts.push({ ...row.stockReceipts[0] }), row => row.inventoryOperations[0].status = 'PROCESSING', + row => row.palletComposition[0].availablePackages += 1, row => row.stockInLedger[0].deltaPackages += 1, + row => row.receivingOperations[0].idempotencyKey = 'ANOTHER', + ] + for (const mutate of mutations) { + const { scope, after, stages } = receivingProofFixture() + mutate(after); stages.AFTER_COMPLETE = normalizeReceivingEvidence(after) + assert.equal(receivingCompletionProof(scope, stages).passed, false) + } +}) + +test('same-key replay and fresh-key rejection must preserve every bucket, position, pallet and immutable row', () => { + for (const stage of ['AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION']) { + for (const mutate of [row => row.balances[0].availablePackages++, row => row.stockReceipts.pop(), + row => row.receivingOperations.push({ ...row.receivingOperations[0], id: '999' }), + row => row.stockInLedger[0].sourceEventId = 'OTHER']) { + const { scope, stages } = receivingProofFixture(); mutate(stages[stage]) + assert.equal(receivingCompletionProof(scope, stages).passed, false) + } + } +}) + +test('normalization is deterministic, preserves zero buckets, and excludes token/operator/provider fields', () => { + const { after } = receivingProofFixture() + after.token = 'SECRET'; after.stockReceipts[0].password = 'SECRET'; after.receivingOperations[0].operatorKey = 'PERSON' + const normal = normalizeReceivingEvidence(after) + const reversed = { ...after, balances: [...after.balances].reverse(), stockReceipts: [...after.stockReceipts].reverse() } + assert.deepEqual(normalizeReceivingEvidence(reversed), normal) + assert.equal(normal.balances[0].procurementPackages, 0) + assert.doesNotMatch(JSON.stringify(normal), /SECRET|PERSON|password|operatorKey|token/) + assert.equal(normalizeReceivingEvidence({ ...after, stockReceipts: undefined }), null) +}) + +test('missing read-only receipt/key reader is a blocker and retains an unproven boundary without inventing HTTP', async () => { + const checks = [], target = { stages: {} } + const context = { context: {}, addCoverageGap() {}, assert(m, n, r, passed) { checks.push(passed) }, + async request() { assert.fail('missing provider cannot pretend public DTO supplies raw keys') } } + const { scope } = receivingProofFixture() + assert.equal(await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', target), null) + assert.deepEqual(checks, [false]); assert.equal(target.stages.BEFORE_COMPLETE.captured, false) +}) + +test('capture independently reads complete unfiltered customer warehouse pages and saves only sanitized live facts', async () => { + const { scope, after } = receivingProofFixture(); const calls = [], checks = [] + const context = { context: { async phase4ReceivingEvidenceReader(actual) { + assert.equal(actual.stage, 'AFTER_COMPLETE'); assert.deepEqual(actual.inboundCodes, ['ONE', 'TWO']) + return { ...after, token: 'SECRET' } + } }, async request(method, requestPath) { + calls.push(requestPath) + const data = requestPath.includes('/balances?') ? after.httpGroups : after.httpLedger + return { http: 200, body: { code: '200', data: { records: data, total: data.length } } } + }, addStep(m, n, r, passed) { checks.push(passed) }, assert(m, n, r, passed) { checks.push(passed) }, addCoverageGap() { assert.fail('valid fixture must not gap') } } + const target = { stages: {} } + const snapshot = await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', target) + assert.equal(receivingEvidenceValid(snapshot, scope), true); assert.ok(checks.every(Boolean)) + assert.ok(calls.every(requestPath => requestPath.includes('customerId=1') && requestPath.includes('overseasWarehouseId=2') && !requestPath.includes('inboundCode='))) + assert.doesNotMatch(JSON.stringify(target), /SECRET/) +}) test('same-root FBA cross-container rejection uses explicit real receiving prefix while preserving original physical box code',() => { const current={containerNo:'ROOT-CABINET',inbounds:[{inboundCode:'ROOT'}]} @@ -126,7 +256,9 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne assert.match(source, /traceId=/) assert.doesNotMatch(source, /https?:\/\//) assert.doesNotMatch(source, /fetch\(|process\.env/) - assert.doesNotMatch(source, /planNo|allocationId/) + assert.doesNotMatch(source, /planNo/) + // Allocation identity is read-only evidence; PDA command URLs/bodies remain container-scoped. + assert.doesNotMatch(source, /\/allocations\//) }) test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 55a13985d..3d05cd717 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -61,6 +61,165 @@ function receivingSnapshot(data) { }) } +const RECEIVING_BALANCE_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages'] +const RECEIVING_POSITION_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const RECEIVING_FIELDS = { + balances: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS], + positions: ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseCode', 'palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'status', ...RECEIVING_POSITION_BUCKETS], + stockReceipts: ['id', 'customerId', 'planId', 'taskId', 'allocationId', 'lineId', 'orderId', 'inboundCode', 'skuCode', 'warehousePalletId', 'palletNo', 'warehouseLocationId', 'warehouseLocationCode', 'batchNo', 'receivedPackages', 'inventoryBalanceId', 'inventoryPositionId', 'inventoryOperationKey'], + inventoryOperations: ['id', 'customerId', 'idempotencyKey', 'operationType', 'requestHash', 'status'], + stockInLedger: ['id', 'customerId', 'balanceId', 'positionId', 'overseasWarehouseId', 'inboundOrderId', 'inboundCode', 'palletId', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'sourceEventId', 'idempotencyKey', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'], + receivingOperations: ['id', 'sessionId', 'action', 'idempotencyKey', 'requestHash'], + httpGroups: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS], + httpLedger: ['id', 'customerId', 'overseasWarehouseId', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'], +} +RECEIVING_FIELDS.palletComposition = RECEIVING_FIELDS.positions +const receivingNumberFields = new Set([...RECEIVING_BALANCE_BUCKETS, 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages']) +const receivingArrays = Object.keys(RECEIVING_FIELDS) +const sameEvidence = (left, right) => JSON.stringify(left) === JSON.stringify(right) +const receivingTotal = (rows, field) => rows.reduce((total, row) => total + row[field], 0) +const balanceGroupKey = row => JSON.stringify([row.customerId, row.overseasWarehouseId, row.destinationWarehouseId, row.destinationWarehouseCode, row.skuCode]) + +// Only safe business identities and bucket facts enter reports. Never save reader connection, +// tokens, operator identifiers, request/result JSON, binary files or arbitrary provider fields. +export function normalizeReceivingEvidence(value) { + if (!value || receivingArrays.some(name => !Array.isArray(value[name]))) return null + return Object.fromEntries(receivingArrays.map(name => [name, value[name].map(row => + Object.fromEntries(RECEIVING_FIELDS[name].map(field => [field, receivingNumberFields.has(field) + ? (row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) ? Number(row[field]) : null) + : row[field] == null ? null : id(row[field])]))).sort((a, b) => id(a.id).localeCompare(id(b.id)))])) +} + +export function receivingEvidenceValid(snapshot, scope) { + if (!snapshot || receivingArrays.some(name => !Array.isArray(snapshot[name]) || snapshot[name].some(row => !longId(row.id)) + || new Set(snapshot[name].map(row => row.id)).size !== snapshot[name].length)) return false + const bucketsValid = (row, fields) => fields.every(field => Number.isSafeInteger(row[field]) && row[field] >= 0) + const owned = row => row.customerId === scope.customerId && row.overseasWarehouseId === scope.warehouseId + if (!snapshot.balances.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS)) + || !snapshot.httpGroups.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS)) + || !snapshot.httpLedger.every(owned) + || !snapshot.positions.every(row => owned(row) && bucketsValid(row, RECEIVING_POSITION_BUCKETS) + && snapshot.balances.some(balance => balance.id === row.balanceId) && longId(row.palletId) && nonBlank(row.palletNo) + && longId(row.warehouseLocationId) && nonBlank(row.warehouseLocationCode)) + || !sameEvidence(snapshot.positions, snapshot.palletComposition)) return false + if (!snapshot.balances.every(balance => RECEIVING_POSITION_BUCKETS.every(field => + receivingTotal(snapshot.positions.filter(row => row.balanceId === balance.id), field) === balance[field]))) return false + const groups = new Map() + for (const balance of snapshot.balances) { + const key = balanceGroupKey(balance) + const current = groups.get(key) || Object.fromEntries(RECEIVING_BALANCE_BUCKETS.map(field => [field, 0])) + for (const field of RECEIVING_BALANCE_BUCKETS) current[field] += balance[field] + groups.set(key, current) + } + for (const [key, totals] of groups) if (!RECEIVING_BALANCE_BUCKETS.some(field => totals[field] > 0)) groups.delete(key) + if (groups.size !== snapshot.httpGroups.length || new Set(snapshot.httpGroups.map(balanceGroupKey)).size !== snapshot.httpGroups.length + || !snapshot.httpGroups.every(row => groups.has(balanceGroupKey(row)) && RECEIVING_BALANCE_BUCKETS.every(field => row[field] === groups.get(balanceGroupKey(row))[field]))) return false + if (!snapshot.stockReceipts.every(row => row.customerId === scope.customerId && scope.inboundCodes.includes(row.inboundCode) + && longId(row.planId) && longId(row.taskId) && longId(row.allocationId) && longId(row.lineId) && longId(row.orderId) + && nonBlank(row.skuCode) && Number.isSafeInteger(row.receivedPackages) && row.receivedPackages > 0)) return false + if (snapshot.inventoryOperations.length !== snapshot.stockReceipts.length || snapshot.stockInLedger.length !== snapshot.stockReceipts.length) return false + for (const receipt of snapshot.stockReceipts) { + const baseKey = `PDA_STOCK_IN:${receipt.taskId}:${receipt.allocationId}:${receipt.skuCode}` + const operations = snapshot.inventoryOperations.filter(row => row.idempotencyKey === baseKey) + if (receipt.inventoryOperationKey !== baseKey || operations.length !== 1) return false + const operation = operations[0] + const ledgers = snapshot.stockInLedger.filter(row => row.idempotencyKey === `${baseKey}:${operation.id}`) + if (operation.customerId !== scope.customerId || operation.operationType !== 'STOCK_IN' || operation.status !== 'COMPLETED' + || !/^[a-f0-9]{64}$/.test(operation.requestHash || '') || ledgers.length !== 1) return false + const ledger = ledgers[0] + const balance = snapshot.balances.find(row => row.id === receipt.inventoryBalanceId) + const position = snapshot.positions.find(row => row.id === receipt.inventoryPositionId) + if (!balance || !position || position.balanceId !== balance.id || !owned(ledger) || ledger.businessType !== 'STOCK_IN' + || ledger.balanceId !== balance.id || ledger.positionId !== position.id || ledger.inboundOrderId !== receipt.orderId + || ledger.inboundCode !== receipt.inboundCode || ledger.skuCode !== receipt.skuCode || !nonBlank(ledger.sourceEventId) + || ledger.palletId !== receipt.warehousePalletId || ledger.palletNo !== receipt.palletNo || ledger.batchNo !== receipt.batchNo + || position.palletId !== receipt.warehousePalletId || position.palletNo !== receipt.palletNo + || position.warehouseLocationId !== receipt.warehouseLocationId || position.warehouseLocationCode !== receipt.warehouseLocationCode + || ledger.deltaPackages !== receipt.receivedPackages || ledger.afterPackages - ledger.beforePackages !== receipt.receivedPackages + || ledger.statusTo !== 'AVAILABLE') return false + } + return snapshot.receivingOperations.every(row => row.action === 'COMPLETE' && longId(row.sessionId) + && nonBlank(row.idempotencyKey) && /^[a-f0-9]{64}$/.test(row.requestHash || '')) +} + +export function receivingCompletionProof(scope, stages) { + const names = ['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION'] + const snapshotsValid = names.every(name => receivingEvidenceValid(stages[name], scope)) + const checks = { completeScope: scope.inboundCodes.length > 0 && new Set(scope.inboundCodes).size === scope.inboundCodes.length + && scope.inboundCodes.every(code => Number.isSafeInteger(scope.expectedPackagesByInbound[code]) && scope.expectedPackagesByInbound[code] > 0) + && scope.sourceInboundIds.length === scope.inboundCodes.length && new Set(scope.sourceInboundIds).size === scope.sourceInboundIds.length, + fourCompleteSnapshots: snapshotsValid } + if (!snapshotsValid) return { passed: false, checks } + const before = stages.BEFORE_COMPLETE, after = stages.AFTER_COMPLETE + const expected = Object.values(scope.expectedPackagesByInbound).reduce((sum, count) => sum + count, 0) + checks.freshSourceHasNoPriorStock = ['stockReceipts', 'inventoryOperations', 'stockInLedger', 'receivingOperations'].every(name => before[name].length === 0) + checks.exactSourceReceiptAndLedgerQuantities = scope.inboundCodes.every(code => receivingTotal(after.stockReceipts.filter(row => row.inboundCode === code), 'receivedPackages') === scope.expectedPackagesByInbound[code] + && receivingTotal(after.stockInLedger.filter(row => row.inboundCode === code), 'deltaPackages') === scope.expectedPackagesByInbound[code]) + checks.completeCommandExactlyOnce = after.receivingOperations.length === 1 && after.receivingOperations[0].idempotencyKey === scope.completeKey + checks.firstCompleteAllBalanceBuckets = RECEIVING_BALANCE_BUCKETS.every(field => receivingTotal(after.balances, field) - receivingTotal(before.balances, field) + === (field === 'availablePackages' ? expected : field === 'inTransitPackages' ? -scope.expectedTransitPackages : 0)) + checks.firstCompleteAllPositionBuckets = RECEIVING_POSITION_BUCKETS.every(field => receivingTotal(after.positions, field) - receivingTotal(before.positions, field) + === (field === 'availablePackages' ? expected : 0)) + const touchedBalances = new Set(after.stockReceipts.map(row => row.inventoryBalanceId)) + const touchedPositions = new Set(after.stockReceipts.map(row => row.inventoryPositionId)) + checks.untouchedBalancesAndPositionsUnchanged = sameEvidence(before.balances.filter(row => !touchedBalances.has(row.id)), after.balances.filter(row => !touchedBalances.has(row.id))) + && sameEvidence(before.positions.filter(row => !touchedPositions.has(row.id)), after.positions.filter(row => !touchedPositions.has(row.id))) + const oldLedgerIds = new Set(before.httpLedger.map(row => row.id)) + const delta = after.httpLedger.filter(row => !oldLedgerIds.has(row.id)) + checks.publicImmutableLedgerPreservedAndNewStockExact = sameEvidence(before.httpLedger, after.httpLedger.filter(row => oldLedgerIds.has(row.id))) + && delta.filter(row => row.businessType === 'STOCK_IN').length === after.stockInLedger.length + && delta.filter(row => row.businessType === 'STOCK_IN').every(row => after.stockInLedger.some(source => source.id === row.id && source.deltaPackages === row.deltaPackages)) + && delta.every(row => scope.inboundCodes.includes(row.inboundCode) && ['STOCK_IN', 'STOCK_IN_TRANSIT_RELEASE'].includes(row.businessType)) + checks.sameKeyReplayAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_SAME_KEY_REPLAY) + checks.freshKeyRejectedAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_FRESH_KEY_REJECTION) + return { passed: Object.values(checks).every(Boolean), checks, expectedPackages: expected, expectedTransitPackages: scope.expectedTransitPackages } +} + +async function receivingEvidencePages(context, scope, resource, name) { + const collected = [] + for (let page = 1; page <= 100; page++) { + const response = await context.request('GET', `/api/platform/v1/last-leg/inventory/${resource}?customerId=${encodeURIComponent(scope.customerId)}&overseasWarehouseId=${encodeURIComponent(scope.warehouseId)}&page=${page}&size=200`) + const data = response.body?.data + const valid = success(response) && Array.isArray(data?.records) && Number.isSafeInteger(Number(data?.total)) && Number(data.total) >= 0 + context.addStep(MODULE, `${name}完整客户仓${resource}第${page}页`, response, valid, trace(response), OPTIONS) + if (!valid) return null + collected.push(...data.records) + if (collected.length >= Number(data.total)) { + const complete = collected.length === Number(data.total) && new Set(collected.map(row => id(row.id))).size === collected.length + context.assert(MODULE, `${name}${resource}分页无截断重复`, response, complete, `rows=${collected.length}, total=${data.total}`, OPTIONS) + return complete ? collected : null + } + if (!data.records.length) break + } + blockerGap(context, `${name}库存分页`, '完整客户仓分页未读完,不能冻结截断库存', 'receiving-replay-evidence') + return null +} + +export async function captureReceivingEvidence(context, scope, stage, target) { + const slot = target.stages[stage] = { captured: false, evidence: null } + const reader = context.context?.phase4ReceivingEvidenceReader + if (typeof reader !== 'function') { + blockerGap(context, `${stage}原始库存与不可变完整键取证`, '未注入phase4ReceivingEvidenceReader;公共ledger不返回原始balance/position/幂等键且无immutable receipt接口,不能以终态补历史快照', 'receiving-replay-evidence') + return null + } + try { + const httpGroups = await receivingEvidencePages(context, scope, 'balances', stage) + const httpLedger = await receivingEvidencePages(context, scope, 'ledger', stage) + if (!httpGroups || !httpLedger) return null + const raw = await reader({ ...scope, stage }) + const evidence = normalizeReceivingEvidence({ ...raw, httpGroups, httpLedger }) + slot.evidence = evidence + slot.captured = receivingEvidenceValid(evidence, scope) + context.assert(MODULE, `${stage}完整raw库存位置托盘组成及收货完整键核平`, null, slot.captured, + `bookingId=${scope.bookingId}, balances=${evidence?.balances.length ?? 'missing'}, positions=${evidence?.positions.length ?? 'missing'}, receipts=${evidence?.stockReceipts.length ?? 'missing'}`, OPTIONS) + return evidence + } catch { + slot.error = 'Read-only receiving evidence reader failed; sensitive connection/provider details omitted' + blockerGap(context, `${stage}只读取证`, slot.error, 'receiving-replay-evidence') + return null + } +} + export function expandOverseasCartonCandidates(inbounds, producerCartonMarks = []) { const marks = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) .map(id).filter(nonBlank) @@ -533,7 +692,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa return cartonFixtures } -async function probeCartonResolveNegatives(context, token, current) { +export async function probeCartonResolveNegatives(context, token, current, runCrossContainerProbe = true) { const fba = current.inbounds.find((item) => item.destinationType === 'FBA') await expected(context, token, 'POST', '无#箱码被服务端拒绝', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', @@ -545,6 +704,7 @@ async function probeCartonResolveNegatives(context, token, current) { '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected) + if (runCrossContainerProbe) { const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || [] const crossContainerProbe = crossContainerCartonProbe(allProducers,current) if (crossContainerProbe) { @@ -556,6 +716,7 @@ async function probeCartonResolveNegatives(context, token, current) { } else { blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') } + } const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS') if (overseas) { @@ -747,7 +908,9 @@ export async function runReceiving(context, token, secondaryToken, merchantToken const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(current.response)) return const beforeResolve = receivingSnapshot(current.data) - await probeCartonResolveNegatives(context, token, current.data) + // Single-container completion/cancellation continuation does not provision a second + // loaded source. The default full receiving chain still requires its cross-container proof. + await probeCartonResolveNegatives(context, token, current.data, runAuthorizationProbes) const producerCartonMarks = preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer) @@ -841,11 +1004,35 @@ export async function runReceiving(context, token, secondaryToken, merchantToken return } const completeBody = { idempotencyKey: key(context, 'complete') } + const sourceInboundIds = [...new Set([id(preferredProducer?.sourceInboundId), + ...(preferredProducer?.palletLabelManifest?.groups || []).flatMap(group => (group.labels || []).map(label => id(label.inboundId)))].filter(longId))].sort() + const scope = { customerId: id(preferredProducer?.tenantId), warehouseId: id(refreshed.data?.overseasWarehouseId), + bookingId: sourceContainerBookingId, containerNo, + inboundCodes: refreshed.data.inbounds.map(inbound => inbound.inboundCode).sort(), sourceInboundIds, + expectedPackagesByInbound: Object.fromEntries(refreshed.data.inbounds.map(inbound => [inbound.inboundCode, Number(inbound.totalPackages)])), + expectedTransitPackages: refreshed.data.inbounds.filter(inbound => inbound.orderType === 'FIRST_LEG').reduce((sum, inbound) => sum + Number(inbound.totalPackages), 0), + completeKey: completeBody.idempotencyKey, freshKey: key(context, 'complete-fresh-repeat') } + const receivingProof = { scope, stages: {}, proof: null, status: 'UNPROVEN' } + context.createdResources.phase4OverseasPda.receivingCompletionEvidence ||= {} + context.createdResources.phase4OverseasPda.receivingCompletionEvidence[sourceContainerBookingId] = receivingProof + await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', receivingProof) const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', receivingProof) if (!success(completed.response)) return const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, - success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) + success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED' && sameEvidence(completeReplay.data, completed.data), trace(completeReplay.response), OPTIONS) + await captureReceivingEvidence(context, scope, 'AFTER_SAME_KEY_REPLAY', receivingProof) + const freshComplete = await expected(context, token, 'POST', '已完成柜新键重复完成被拒绝且不能再次写库存', + '/api/pda/v1/last-leg/receiving-containers/current/complete', { idempotencyKey: scope.freshKey }, + response => a0404Message(response, /^Current receiving container session was not found$/)) + await captureReceivingEvidence(context, scope, 'AFTER_FRESH_KEY_REJECTION', receivingProof) + receivingProof.proof = receivingCompletionProof(scope, Object.fromEntries(Object.entries(receivingProof.stages).map(([stage, row]) => [stage, row.evidence]))) + receivingProof.commandResults = { firstCompleteSucceeded: success(completed.response), sameKeyReturnedExactOriginal: success(completeReplay.response) && sameEvidence(completeReplay.data, completed.data), + freshKeyRejected: a0404Message(freshComplete, /^Current receiving container session was not found$/) } + receivingProof.status = receivingProof.proof.passed && Object.values(receivingProof.commandResults).every(Boolean) ? 'VERIFIED' : 'UNPROVEN' + for (const [check, passed] of Object.entries(receivingProof.proof.checks)) context.assert(MODULE, `收货完成库存守恒 ${check}`, null, passed, + `bookingId=${sourceContainerBookingId}, proofStatus=${receivingProof.status}`, OPTIONS) const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 25b7b3f51..8ca8cf005 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test' import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, - completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' + completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, + dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -9,6 +10,28 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8') const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8') +test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{ + const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']} + const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'}, + {planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]} + const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}] + assert.equal(dispatchDestinationConservation(detail,fixture),true) + assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true) + for(const mutate of [ + value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER', + value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER', + value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10', + value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(), + value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}), + value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}), + ]) { + const value=structuredClone({detail,fixture,deliveries});mutate(value) + assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false) + } + assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/) + assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/) +}) + test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => { assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/) assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index f136606d2..d86984c1a 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -57,6 +57,25 @@ function deliveryNumbers(data) { return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort() } +export function dispatchDestinationConservation(detail, fixture, deliveries = null) { + const codes = values => values.map(value => id(value).trim().toUpperCase()) + const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) + && right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort()) + const items = detail?.items + if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds) + || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false + const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id) + if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length + || new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false + const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode)) + const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(',')) + if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false + if (deliveries == null) return true + if (!Array.isArray(deliveries) || !deliveries.length) return false + const delivered=codes(deliveries.map(row => row.destinationWarehouseCode)) + return delivered.length === new Set(delivered).size && sameSet(delivered,actual) +} + async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { let result = null for (let attempt = 1; attempt <= attempts; attempt += 1) { @@ -873,6 +892,14 @@ export async function run(context) { const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return + const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = { + frozenPlanPalletIds:source.dispatch.planPalletIds, + frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes, + beforeEdit:beforeEdit.data, + } + context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS) + if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return const updateBody = { vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), driverName: `五期验收司机-${context.marker.slice(-6)}`, @@ -909,6 +936,10 @@ export async function run(context) { const deliveries = transferDeliveries(transferred.data) const deliveryNos = deliveryNumbers(transferred.data) const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) + routeProof.deliveries = deliveries + context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS) + if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return const deliveryNo = deliveryNos[0] || '' context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index d237ea7b3..d6fe85f94 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -3,7 +3,9 @@ import test from 'node:test' import { readFileSync } from 'node:fs' import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials, - f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, run } from './phase5-root-fulfillment.mjs' + f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, + validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder, + verifyRootProjections, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs' import { loadOneFragment } from './phase3-order-splitting.mjs' import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, @@ -30,6 +32,70 @@ const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdRe addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data}, }) +test('F40 unified projections require exact root and customer session with final COMPLETED or unfinished DELIVERED',async()=>{ + for (const completed of [true,false]) { + const context=fixture(),calls=[],snapshot={root:{status:completed?'COMPLETED':'TRANSPORTING',histories:history('OVERSEAS_DELIVERED')}} + context.request=async(method,path,body,token)=>{ + calls.push({method,path,token}) + return {http:200,body:{code:'200',data:{summary:{orderId:'100',mainStatus:completed?'COMPLETED':'DELIVERED'}}}} + } + const result=await verifyRootProjections(context,'90','100','customer-session',snapshot) + assert.deepEqual(result.platform,result.customer) + assert.equal(calls[0].path,'/api/platform/v1/orders/100?tenantId=90') + assert.equal(calls[1].path,'/api/merchant/v1/orders/100') + assert.equal(calls[1].token,'customer-session') + assert.equal(context.steps.every(step=>step.passed),true) + } +}) +test('F40 customer mismatched root or prematurely completed projection fails closed',async()=>{ + for (const summary of [{orderId:'101',mainStatus:'COMPLETED'},{orderId:'100',mainStatus:'DELIVERED'}]) { + const context=fixture() + context.request=async(_method,path)=>({http:200,body:{code:'200',data:{summary: + path.startsWith('/api/platform')?{orderId:'100',mainStatus:'COMPLETED'}:summary}}}) + await assert.rejects(verifyRootProjections(context,'90','100','customer-session',{root:{status:'COMPLETED',histories:[]}}), + /平台客户统一状态/) + } +}) +test('F40 README documents all three independent modes and fail-closed receiving/zero child event evidence',()=>{ + const readme=readFileSync(new URL('./README.md',import.meta.url),'utf8') + for(const value of ['CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED','orderStopAtApproved=true', + 'PROCESSING','WAIT_SPLIT','A0409','phase4ReceivingEvidenceReader','零事件子行'])assert.ok(readme.includes(value),value) + assert.match(source,/proof\.beforeReceiptReplayOutbox = await waitState/) + assert.match(source,/outboxCounts\(proof.beforeReceiptReplayOutbox\) === outboxCounts\(proof.outboxProof\)/) + assert.match(source,/validFreshReceiptSave\(beforeFresh,afterFresh\)/) + assert.match(source,/JSON.stringify\(afterFresh\) === JSON.stringify\(receiptProof.afterSameKey\)/) +}) + +test('fresh receipt save allows exactly one document version/revision/audit while business completion stays unchanged',()=>{ + const documents={version:'2',uploadedCategoryCount:1,categories:[{category:'RECEIPT',files:[{fileId:'1'}]}],replayed:false} + const before={documents,delivery:{order:{version:'2',status:'DELIVERED',signedAt:'2026-09-30T16:00:00'}, + operations:[{id:'1',operationNode:'RECEIPT_AUTO_COMPLETE'}],skus:[{packageCount:1}],fees:[]},revisions:[{version:'2'}]} + const after=structuredClone(before);after.documents.version='3';after.delivery.order.version='3' + after.delivery.operations.push({id:'2',operationNode:'SAVE_DOCUMENTS',result:'保存派送资料:1/3类', + operatedAt:'2026-09-30T16:01:00',operatorName:'platform'}) + after.revisions.push({version:'3',savedAt:'2026-09-30T16:01:00',operatorId:'platform',snapshot:after.documents}) + assert.equal(validFreshReceiptSave(before,after),true) + for(const mutate of [ + value=>value.documents.version='4',value=>value.delivery.order.version='2', + value=>value.delivery.order.signedAt='2026-09-30T16:01:00',value=>value.delivery.order.status='RETURNED', + value=>value.documents.categories[0].files[0].fileId='2',value=>value.delivery.skus[0].packageCount=2, + value=>value.delivery.fees.push({amount:1}),value=>value.delivery.operations[0].id='999', + value=>value.delivery.operations[1].operationNode='RECEIPT_AUTO_COMPLETE', + value=>value.delivery.operations.push({...value.delivery.operations[1],id:'3'}), + value=>value.revisions.pop(),value=>value.revisions[0].version='1', + ]){const bad=structuredClone(after);mutate(bad);assert.equal(validFreshReceiptSave(before,bad),false)} +}) +test('F40 retains partial and failed receiving raw snapshots without exporting identity credentials',()=>{ + const proof={},scope={createdResources:{phase4OverseasPda:{receivingCompletionEvidence:{}}},token:'secret'} + preserveReceivingEvidence(proof,scope) + scope.createdResources.phase4OverseasPda.receivingCompletionEvidence['20']={status:'UNPROVEN',stages:{ + BEFORE_COMPLETE:{captured:true,evidence:{balances:[]}},AFTER_COMPLETE:{captured:false,evidence:{balances:[{id:'1'}]}}}} + assert.deepEqual(proof.receivingCompletionEvidence,scope.createdResources.phase4OverseasPda.receivingCompletionEvidence) + assert.equal(JSON.stringify(proof).includes('secret'),false) + assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\);/) + assert.match(source,/proof.receiptReplayProofs \|\|= \[\]/) +}) + test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => { const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8') const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'} @@ -175,17 +241,141 @@ test('exported receiving warehouse resolver continues to choose only configured ]}}}) assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'}) }) -test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback, SQL or cancellation substitute',() => { +test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback or SQL',() => { assert.equal(typeof run,'function') for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch', 'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`)) for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption', 'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete']) assert.match(source,new RegExp(`await ${name}\\(`)) - assert.match(source,/rootOrderId, sourceOrderIds:\[rootOrderId,childOrderId\]/) + assert.match(source,/rootOrderId,\s*sourceOrderIds:\[rootOrderId,childOrderId\]/) assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/) - assert.match(source,/finally \{ proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/) - assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/cancel|\/complete-delivery/) + assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/) + assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/complete-delivery/) +}) + +const oneLoadedTree = () => ({...tree(),cumulativeLoadedPackageCount:1,currentRemainderPackageCount:1, + children:tree().children.slice(0,2).map(row=>({...row,loadingPlanId:'30',containerBookingId:'20'}))}) +const approvedChild = () => fulfillmentHistorySnapshot({order:{id:'101',tenantId:'90',orderRole:'CHILD', + rootOrderId:'100',parentOrderId:'100',status:'APPROVED',version:1,orderNo:'R-1',inboundCode:'I-1',completedAt:null},statusHistory:[]}) +const unshippedInbound = () => ({id:'301',tenantId:'90',orderId:'101',orderNo:'R-1',inboundCode:'I-1', + processStatus:'WAIT_SPLIT',orderStatusSnapshot:'APPROVED',totalPackageCount:1,containerNo:null,cabinetNo:null}) +const cancelled = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')}, + child:{status:'CANCELLED',rawOrder:{completedAt:null},histories:history('CANCELLED')}, + rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}}) +const cancellationOutbox = () => {const value=outbox();value.orders[1].count=0;value.orders[1].publishedCount=0;return value} + +test('unloaded remainder requires real raw APPROVED child, exact customer/root/source and one remaining physical box',() => { + const child=approvedChild(),inbound=unshippedInbound(),tree=oneLoadedTree() + assert.equal(validUnloadedRemainder(tree,child,inbound,'90','100','101'),true) + for(const mutate of [ + value => value.child.status='PROCESSING',value => value.child.status='ACCEPTED', + value => value.child.rootOrderId='999',value => value.child.parentOrderId='999', + value => value.child.rawOrder.tenantId='91',value => value.child.orderId='102', + value => value.child.histories=history('OVERSEAS_DELIVERED'), + value => value.inbound.orderId='102',value => value.inbound.tenantId='91', + value => value.inbound.inboundCode='ANOTHER',value => value.inbound.orderNo='ANOTHER', + value => value.inbound.processStatus='LOADED',value => value.inbound.totalPackageCount=2, + value => value.inbound.orderStatusSnapshot='PROCESSING',value => value.inbound.containerNo='ACTUAL-CABINET', + value => value.inbound.cabinetNo='ACTUAL-CABINET',value => value.tree.children.push(tree.children[0]), + value => value.tree.children[0].sourceOrderId='101',value => value.tree.children[1].loadingPlanId='31', + value => value.tree.children[1].containerBookingId='21',value => value.tree.currentRemainderPackageCount=0, + ]) { + const value={tree:oneLoadedTree(),child:approvedChild(),inbound:unshippedInbound()};mutate(value) + assert.equal(validUnloadedRemainder(value.tree,value.child,value.inbound,'90','100','101'),false) + } +}) +test('cancelled remainder cannot substitute for child signature in either full-signature mode',() => { + assert.equal(validRemainderCancellationGate(cancelled()),true) + assert.equal(validSignatureGate(cancelled(),'MASTER',true),false) + assert.equal(validSignatureGate(cancelled(),'CHILD',true),false) + for(const mutate of [ + value=>value.root.histories=history('FULFILLMENT_COMPLETED'), + value=>value.root.histories.push({action:'FULFILLMENT_COMPLETED'}), + value=>value.child.status='COMPLETED',value=>value.child.histories.push({action:'OVERSEAS_DELIVERED'}), + value=>value.child.histories.push({action:'CANCELLED'}),value=>value.child.rawOrder.completedAt='2026-09-30T01:00:00', + value=>value.rootInbound.orderStatusSnapshot='APPROVED',value=>value.rootInbound.processStatus='RECEIVED', + ]){const value=cancelled();mutate(value);assert.equal(validRemainderCancellationGate(value),false)} +}) +test('cancellation Outbox requires exactly one published root completion and explicit zero child completion counts',() => { + assert.equal(exactCancelledRemainderOutboxProof(cancellationOutbox(),'90','100','101'),true) + assert.equal(exactCompletionOutboxProof(cancellationOutbox(),'90','100',['100','101']),false) + for(const mutate of [ + value=>value.orders.pop(),value=>value.orders[1].count=1,value=>value.orders[1].publishedCount=1, + value=>value.orders[1].pendingCount=1,value=>value.orders[1].failedCount=1, + value=>value.orders[1].count=null,value=>delete value.orders[1].failedCount, + value=>value.orders[1].rootOrderId='999',value=>value.orders[1].customerId='91', + value=>value.orders[1].orderId='100',value=>value.orders[1].eventType='ORDER_CANCELLED', + value=>value.orders[0].count=2,value=>value.orders[0].publishedCount=0,value=>value.readOnly=false, + ]){const value=cancellationOutbox();mutate(value);assert.equal(exactCancelledRemainderOutboxProof(value,'90','100','101'),false)} +}) +function cancellationFixture(change=null) { + const context=fixture(),calls=[],proof={snapshots:[]} + const rootDetail={order:{id:'100',tenantId:'90',orderRole:'MASTER',rootOrderId:'100',status:'APPROVED', + version:1,orderNo:'R',inboundCode:'I',completedAt:null},statusHistory:history('OVERSEAS_DELIVERED')} + const childDetail={order:approvedChild().rawOrder,statusHistory:[]} + const rootInbound={summary:{processStatus:'DELIVERED',orderStatusSnapshot:'APPROVED'}} + const childInbound={summary:unshippedInbound()},tree=oneLoadedTree() + let count=0 + context.context={f40BaseMarker:'offline-only',f40ReadOnlyOutboxEvidence:async args=>{ + assert.deepEqual(args,{customerId:'90',rootOrderId:'100',sourceOrderIds:['100','101'], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) + return cancellationOutbox() + }} + if(change)change({rootDetail,childDetail,childInbound,tree}) + context.request=async(method,path,body)=>{ + calls.push({method,path,body}) + if(method==='POST'){ + assert.equal(path,'/api/platform/v1/orders/first-leg/101/cancel?tenantId=90') + assert.ok(body.reason.includes('offline-only')) + if(count++)return {http:200,body:{code:'A0409'}} + childDetail.order.status='CANCELLED';childDetail.order.version++ + childDetail.statusHistory=history('CANCELLED') + rootDetail.order.status='COMPLETED';rootDetail.order.version++ + rootDetail.order.completedAt='2026-09-30T01:00:00' + rootDetail.statusHistory=history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED') + rootInbound.summary.orderStatusSnapshot='COMPLETED' + return {http:200,body:{code:'200',data:null}} + } + let data + if(path.includes('/fulfillment-tree?'))data=tree + else if(path.includes('/orders/first-leg/100?'))data=rootDetail + else if(path.includes('/orders/first-leg/101?'))data=childDetail + else if(path.includes('/inbounds/300?'))data=rootInbound + else if(path.includes('/inbounds/301?'))data=childInbound + else throw new Error(`unexpected offline request ${path}`) + return {http:200,body:{code:'200',data:structuredClone(data)}} + } + return {context,proof,calls} +} +test('formal cancellation helper rereads physical eligibility, uses public cancel and accepts replay rejection only with unchanged facts and Outbox',async()=>{ + const {context,proof,calls}=cancellationFixture() + const result=await cancelUnloadedRemainder(context,proof,'90','100','101','300','301') + assert.equal(validRemainderCancellationGate(result),true) + assert.equal(calls.filter(row=>row.method==='POST').length,2) + assert.equal(proof.snapshots.length,3) + assert.deepEqual(proof.cancelReplayOutbox.before,proof.cancelReplayOutbox.after) + assert.equal(context.steps.every(step=>step.passed),true) +}) +test('formal cancellation helper stops before any mutation for PROCESSING, unsigned root or already loaded child',async()=>{ + for(const change of [value=>value.childDetail.order.status='PROCESSING', + value=>value.rootDetail.order.status='PROCESSING',value=>value.rootDetail.statusHistory=[], + value=>value.childInbound.summary.processStatus='LOADED',value=>value.tree.children.push(tree().children[2])]) { + const {context,proof,calls}=cancellationFixture(change) + await assert.rejects(cancelUnloadedRemainder(context,proof,'90','100','101','300','301'),/仅取消真实签收根下未装 APPROVED/) + assert.equal(calls.some(row=>row.method!=='GET'),false) + } +}) +test('cancel scenario uses new approved-only order and exactly root shipment; both existing full-signature modes remain independent',()=>{ + assert.match(source,/const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED'/) + assert.match(source,/orderStopAtApproved:cancelRemainder/) + assert.match(source,/approvedRoot.status === 'APPROVED'/) + assert.match(source,/if \(cancelRemainder\) \{[\s\S]*?proof.unloadedRemainder[\s\S]*?\} else \{[\s\S]*?second = await fragment/) + assert.match(source,/const producers = cancelRemainder \? \[first\] : \[first,second\]/) + assert.match(source,/signatureOrder === 'CHILD_FIRST' \? \[second,first\] : producers/) + assert.match(source,/if \(cancelRemainder\) await cancelUnloadedRemainder/) + const orderSource=readFileSync(new URL('./orders.mjs',import.meta.url),'utf8') + assert.match(orderSource,/context.context\?\.orderStopAtApproved === true/) }) test('early F40 failures become FAILED with only a safe stage and preserve the original exception',async() => { const context=fixture();context.context={};context.options={} diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 829071171..faba94683 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -15,6 +15,36 @@ const success = response => response?.http === 200 && String(response?.body?.cod const query = (path, values) => `${path}?${new URLSearchParams(values)}` export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) } +export function preserveReceivingEvidence(proof, scope) { + proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {} +} +export function validFreshReceiptSave(before, after) { + const previousVersion = Number(before?.documents?.version) + const nextVersion = Number(after?.documents?.version) + if (!Number.isSafeInteger(previousVersion) || nextVersion !== previousVersion + 1 + || Number(before?.delivery?.order?.version) !== previousVersion + || Number(after?.delivery?.order?.version) !== nextVersion + || before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false + const same = (left,right) => JSON.stringify(left) === JSON.stringify(right) + if (!same(before.documents,{...after.documents,version:before.documents.version})) return false + const oldOperations = before.delivery.operations, newOperations = after.delivery.operations + if (!Array.isArray(oldOperations) || !Array.isArray(newOperations) + || newOperations.length !== oldOperations.length + 1) return false + const oldIds = new Set(oldOperations.map(row => id(row.id))) + const added = newOperations.filter(row => !oldIds.has(id(row.id))) + if (added.length !== 1 || !id(added[0].id) || added[0].operationNode !== 'SAVE_DOCUMENTS' + || added[0].result !== `保存派送资料:${after.documents.uploadedCategoryCount}/3类` + || !added[0].operatedAt || !added[0].operatorName + || !same(oldOperations,newOperations.filter(row => oldIds.has(id(row.id))))) return false + if (!same(before.delivery,{...after.delivery,order:{...after.delivery.order,version:before.delivery.order.version}, + operations:oldOperations})) return false + if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions) + || after.revisions.length !== before.revisions.length + 1) return false + const addedRevisions = after.revisions.filter(row => Number(row.version) === nextVersion) + return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId) + && same(before.revisions,after.revisions.filter(row => Number(row.version) !== nextVersion)) + && same(addedRevisions[0].snapshot,after.documents) +} export function resolveF40MerchantCredentials(context) { const fixture = context.context?.merchantQualificationFixture if (!fixture) { @@ -103,6 +133,56 @@ export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, so && Number(row.count) === 1 && Number(row.publishedCount) === 1 && Number(row.pendingCount) === 0 && Number(row.failedCount) === 0) } +export function validUnloadedRemainder(tree, child, inbound, customerId, rootOrderId, childOrderId) { + const rows = Array.isArray(tree?.children) ? tree.children : [] + const loaded = rows.filter(row => row.splitRole === 'LOADED') + const remainder = rows.filter(row => row.splitRole === 'REMAINDER') + return Boolean(id(rootOrderId) && id(childOrderId) && id(rootOrderId) !== id(childOrderId)) + && id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2 + && Number(tree.cumulativeLoadedPackageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1 + && rows.length === 2 && loaded.length === 1 && remainder.length === 1 + && id(loaded[0].orderId) === id(rootOrderId) && id(loaded[0].sourceOrderId) === id(rootOrderId) + && Number(loaded[0].packageCount) === 1 + && id(remainder[0].orderId) === id(childOrderId) && id(remainder[0].sourceOrderId) === id(rootOrderId) + && Number(remainder[0].packageCount) === 1 + && id(remainder[0].loadingPlanId) === id(loaded[0].loadingPlanId) + && id(remainder[0].containerBookingId) === id(loaded[0].containerBookingId) + && Boolean(id(loaded[0].loadingPlanId) && id(loaded[0].containerBookingId)) + && child?.role === 'CHILD' && child.status === 'APPROVED' + && child.orderId === id(childOrderId) && child.rootOrderId === id(rootOrderId) + && child.parentOrderId === id(rootOrderId) && id(child.rawOrder?.tenantId) === id(customerId) + && completionCounts(child).own === 0 && completionCounts(child).final === 0 + && Boolean(id(inbound?.id) && inbound?.inboundCode) + && id(inbound.orderId) === id(childOrderId) && id(inbound.tenantId) === id(customerId) + && inbound.inboundCode === child.inboundCode && inbound.orderNo === child.orderNo + && inbound.processStatus === 'WAIT_SPLIT' && inbound.orderStatusSnapshot === 'APPROVED' + && Number(inbound.totalPackageCount) === 1 && !inbound.containerNo && !inbound.cabinetNo +} +export function validRemainderCancellationGate(snapshot) { + const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child) + return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'CANCELLED' + && root.own === 1 && root.final === 1 && child.own === 0 && child.final === 0 + && snapshot.child.histories.filter(row => row.action === 'CANCELLED').length === 1 + && snapshot.child.rawOrder?.completedAt == null + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED' +} +export function exactCancelledRemainderOutboxProof(evidence, customerId, rootOrderId, childOrderId) { + const rows = Array.isArray(evidence?.orders) ? evidence.orders : [] + const root = rows.filter(row => id(row.orderId) === id(rootOrderId)) + const child = rows.filter(row => id(row.orderId) === id(childOrderId)) + return id(rootOrderId) !== id(childOrderId) && rows.length === 2 && root.length === 1 && child.length === 1 + && exactCompletionOutboxProof({...evidence,orders:root},customerId,rootOrderId,[rootOrderId]) + && id(child[0].customerId) === id(customerId) && id(child[0].rootOrderId) === id(rootOrderId) + && child[0].eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' + && ['count','publishedCount','pendingCount','failedCount'].every(field => child[0][field] != null && Number(child[0][field]) === 0) +} +function outboxCounts(evidence) { + return JSON.stringify((evidence?.orders || []).map(row => ({ + orderId:id(row.orderId),customerId:id(row.customerId),rootOrderId:id(row.rootOrderId),eventType:row.eventType, + count:Number(row.count),publishedCount:Number(row.publishedCount),pendingCount:Number(row.pendingCount),failedCount:Number(row.failedCount), + })).sort((left,right) => left.orderId.localeCompare(right.orderId))) +} function check(context, label, condition, evidence, response = null) { context.assert(MODULE, label, response, condition, evidence, OPTIONS) if (!condition) throw new Error(`${MODULE}: ${label}: ${evidence}`) @@ -138,6 +218,74 @@ async function waitState(read, predicate, attempts = 90) { } return last } +export async function verifyRootProjections(context, customerId, rootOrderId, merchantToken, snapshot) { + const completed = snapshot.root.status === 'COMPLETED' + const ownSigned = completionCounts(snapshot.root).own === 1 + const valid = value => id(value?.summary?.orderId) === id(rootOrderId) + && (completed ? value.summary.mainStatus === 'COMPLETED' + : ownSigned ? value.summary.mainStatus === 'DELIVERED' + : Boolean(value.summary.mainStatus) && value.summary.mainStatus !== 'COMPLETED') + const platform = await waitState(() => api(context,'GET','F40 平台统一根订单状态', + query(`/api/platform/v1/orders/${rootOrderId}`,{tenantId:customerId})),valid) + const customer = await api(context,'GET','F40 本企业客户统一根订单状态', + `/api/merchant/v1/orders/${rootOrderId}`,undefined,merchantToken) + check(context,'F40 平台客户统一状态与原始根完成门禁一致',valid(platform) && valid(customer) + && customer.summary.mainStatus === platform.summary.mainStatus,'exact-root-platform-customer-status') + return {platform:platform.summary,customer:customer.summary} +} +export async function cancelUnloadedRemainder(context, proof, customerId, rootOrderId, childOrderId, + rootInboundId, childInboundId) { + proof.currentStage = 'REMAINDER_CANCEL_PRECONDITION' + const before = await state(context,customerId,rootOrderId,childOrderId,rootInboundId) + const treePath = query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`,{tenantId:customerId}) + const tree = await api(context,'GET','取消前重新核对唯一未消费余量履约树',treePath) + const childInbound = await api(context,'GET','取消前回查未装余量入仓来源', + query(`/api/platform/inbounds/${childInboundId}`,{tenantId:customerId})) + check(context,'F40 仅取消真实签收根下未装 APPROVED 一箱余量', before.root.status === 'APPROVED' + && validSignatureGate(before,'MASTER') + && validUnloadedRemainder(tree,before.child,childInbound?.summary,customerId,rootOrderId,childOrderId), + 'raw-approved-root/child; one-loaded-root/one-unloaded-remainder') + proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary}) + const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId}) + const cancelBody = {reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`} + proof.currentStage = 'REMAINDER_CANCELLATION' + await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody) + const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId), + validRemainderCancellationGate) + check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after), + 'root-own=1/root-final=1/child-cancel=1/child-signature=0') + const afterTree = await api(context,'GET','取消后保留真实分片数量台账',treePath) + check(context,'F40 取消没有伪造第二柜实装或改写分片数量', + JSON.stringify(tree.children) === JSON.stringify(afterTree.children) + && Number(afterTree.cumulativeLoadedPackageCount) === 1 && Number(afterTree.currentRemainderPackageCount) === 1, + 'ledger-retains-one-physical-box-and-one-cancelled-remainder') + proof.snapshots.push({phase:'ROOT_SIGNED_REMAINDER_CANCELLED',...after,tree:afterTree}) + proof.currentStage = 'REMAINDER_CANCEL_REPLAY' + const reader = context.context?.f40ReadOnlyOutboxEvidence + const request = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'} + const beforeOutbox = typeof reader === 'function' ? await waitState(() => reader(request), + value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null + if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零', + exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero') + const replay = await context.request('POST',cancelPath,cancelBody) + check(context,'F40 取消重放按当前非幂等状态接口返回 A0409', + replay?.http === 200 && String(replay?.body?.code) === 'A0409','cancel-replay-conflict',replay) + const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId) + const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath) + check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化', + JSON.stringify(after) === JSON.stringify(stable) && JSON.stringify(afterTree) === JSON.stringify(replayTree), + 'exact-root/child/history/summary/tree') + if (beforeOutbox) { + const afterOutbox = await reader(request) + check(context,'F40 取消重放完成Outbox计数发布状态零变化', + exactCancelledRemainderOutboxProof(afterOutbox,customerId,rootOrderId,childOrderId) + && outboxCounts(beforeOutbox) === outboxCounts(afterOutbox),'exact-root-child-completion-counts') + proof.cancelReplayOutbox = {before:beforeOutbox,after:afterOutbox} + } + proof.snapshots.push({phase:'AFTER_CANCELLATION_REPLAY',...stable,tree:replayTree}) + return stable +} async function domesticLogin(context) { if (context.pdaToken) return const username = context.options.pdaUsername, password = context.options.pdaPassword @@ -242,7 +390,7 @@ async function sendFragment(context, producer, picker, merchantToken, index) { check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, - receiptVersion: completed.order.version, signedAt: completed.order.signedAt } + receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed } } /** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ @@ -266,11 +414,14 @@ async function runRootFulfillment(context) { cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence', } const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST' - check(context,'F40 签收次序为显式正式来源顺序', ['ROOT_FIRST','CHILD_FIRST'].includes(signatureOrder), signatureOrder) + check(context,'F40 签收次序为显式正式来源顺序', + ['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'].includes(signatureOrder), signatureOrder) + const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED' proof.signatureOrder = signatureOrder const scope = Object.create(context) scope.marker = `${context.marker}-F40` scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', + orderStopAtApproved:cancelRemainder, orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' }, waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, phase4OverseasPdaOperations: { completedFixtures: [] }, @@ -292,6 +443,14 @@ async function runRootFulfillment(context) { const orderFixture = { ...scope.createdResources.orders } check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId, 'actual-order-customer-matches-profile') + if (cancelRemainder) { + const approvedRoot = fulfillmentHistorySnapshot(await api(scope,'GET','取消专项回查新根真实审核状态', + query(`/api/platform/v1/orders/first-leg/${orderFixture.orderId}`,{tenantId:expectedCustomerId}))) + check(scope,'F40 取消专项新根必须为原始 APPROVED 而非快照推断',approvedRoot.role === 'MASTER' + && approvedRoot.status === 'APPROVED' && approvedRoot.orderId === id(orderFixture.orderId) + && id(approvedRoot.rawOrder?.tenantId) === expectedCustomerId,'raw-approved-fresh-root') + proof.snapshots.push({phase:'APPROVED_NEW_ROOT',root:approvedRoot}) + } proof.currentStage = 'DOMESTIC_MEASUREMENT' await stage(scope, 'inbound-measurement') const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId) @@ -323,15 +482,26 @@ async function runRootFulfillment(context) { check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`) const childInbound = await api(scope, 'GET', '读取子单可装柜来源', query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) - scope.marker = `${scope.context.f40BaseMarker}-CHILD` - proof.currentStage = 'CHILD_LOAD_AND_SHIP' - const second = await fragment(scope, childInbound, rootOrderId, 1) - proof.fragments.push(second) + let second = null + if (cancelRemainder) { + check(scope,'F40 取消专项真实余量未装且原始 APPROVED', + validUnloadedRemainder(tree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary, + customerId,rootOrderId,childOrderId),'one-approved-unloaded-remainder') + proof.unloadedRemainder = {childOrderId,childInboundId:id(childRows[0].id),tree,summary:childInbound.summary} + } else { + scope.marker = `${scope.context.f40BaseMarker}-CHILD` + proof.currentStage = 'CHILD_LOAD_AND_SHIP' + second = await fragment(scope, childInbound, rootOrderId, 1) + proof.fragments.push(second) + } proof.currentStage = 'FULFILLMENT_TREE' const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树', query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) - check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId) - && first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`) + check(scope, 'F40 真实同根实装及余量数量守恒', cancelRemainder + ? validUnloadedRemainder(finalTree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary, + customerId,rootOrderId,childOrderId) + : validTwoFragmentTree(finalTree, rootOrderId, childOrderId) + && first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`) const before = await state(scope, customerId, rootOrderId, childOrderId, first.inboundId) check(scope, 'F40 尚未签收两片不得出现完成事实', before.root.role === 'MASTER' && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' @@ -346,14 +516,17 @@ async function runRootFulfillment(context) { check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') picker.warehouseId = warehouse.id check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') - scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] } + const producers = cancelRemainder ? [first] : [first,second] + scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers } scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], - overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null } + overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null, + receivingCompletionEvidence:{} } + preserveReceivingEvidence(proof,scope) proof.currentStage = 'OVERSEAS_PLAN_CONVERSION' - check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed') - check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted') - const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second] + check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`) + check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`) + const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers for (const [index, producer] of signatureSources.entries()) { proof.currentStage = `OVERSEAS_RECEIVING_${index+1}` scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}` @@ -376,12 +549,27 @@ async function runRootFulfillment(context) { proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed }) check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次', validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder) + proof.snapshots.at(-1).projections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,observed) } + if (cancelRemainder) await cancelUnloadedRemainder(scope,proof,customerId,rootOrderId,childOrderId, + first.inboundId,id(childRows[0].id)) proof.currentStage = 'RECEIPT_REPLAY' const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable) const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1) - check(scope,'F40 根完成时间为实际两片签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt, + check(scope,'F40 根完成时间为实际有效来源签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt, `completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`) + const reader = context.context?.f40ReadOnlyOutboxEvidence + const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'} + const validOutbox = evidence => cancelRemainder + ? exactCancelledRemainderOutboxProof(evidence,customerId,rootOrderId,childOrderId) + : exactCompletionOutboxProof(evidence,customerId,rootOrderId,[rootOrderId,childOrderId]) + if (typeof reader === 'function') { + proof.beforeReceiptReplayOutbox = await waitState(() => reader(outboxRequest),validOutbox) + check(scope,'F40 重复签收前完成事件各实际来源唯一发布',validOutbox(proof.beforeReceiptReplayOutbox), + 'exact-source-before-receipt-replay') + } // Existing receipt helper replays the exact original save command before each signature observation. // Re-saving completed receipts with a fresh business key is also a no-transition operation. for (const [index,signed] of proof.signatures.entries()) { @@ -390,21 +578,48 @@ async function runRootFulfillment(context) { query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId})) const documents = (current.categories || []).filter(row => row.files?.length).map(row => ({category:row.category, fileIds:row.files.map(file => id(file.fileId))})) - await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', + const documentPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}) + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}`,{customerId}) + const historyPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents/history`,{customerId}) + const readReceipt = async target => { + target.documents = await api(scope,'GET','F40 回查签收资料完整绑定',documentPath) + target.delivery = await api(scope,'GET','F40 回查签收派送完整事实',deliveryPath) + target.revisions = await api(scope,'GET','F40 回查签收资料完整版本历史',historyPath) + return target + } + const beforeFresh = {documents:current,delivery:await api(scope,'GET','F40 新键前冻结派送业务与操作事实',deliveryPath), + revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)} + check(scope,'F40 新键前原签收事实保持完整稳定', + JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`) + const saveBody = {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents} + const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}} + ;(proof.receiptReplayProofs ||= []).push(receiptProof) + const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}), - {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}) + saveBody) + receiptProof.saved = saved + const afterFresh = await readReceipt(receiptProof.afterFresh) + check(scope,'F40 新键资料保存仅递增一次版本及资料审计不得重复完成', + saved.replayed === false && validFreshReceiptSave(beforeFresh,afterFresh),`delivery=${signed.deliveryNo}`) + const sameKeyReplay = await api(scope,'PUT','F40 新资料命令原键原请求重放',documentPath,saveBody) + receiptProof.sameKeyReplay = sameKeyReplay + await readReceipt(receiptProof.afterSameKey) + check(scope,'F40 新资料命令原键重放资料版本历史派送业务及操作零漂移', + sameKeyReplay.replayed === true && JSON.stringify({...sameKeyReplay,replayed:false}) === JSON.stringify(saved) + && JSON.stringify(afterFresh) === JSON.stringify(receiptProof.afterSameKey),`delivery=${signed.deliveryNo}`) } const afterReplay = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增', JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot') proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay}) proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE' - const reader = context.context?.f40ReadOnlyOutboxEvidence if (typeof reader === 'function') { - proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId], - eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) + proof.outboxProof = await reader(outboxRequest) check(scope,'F40 每个实际来源完成Outbox唯一并已发布', - exactCompletionOutboxProof(proof.outboxProof,customerId,rootOrderId,[rootOrderId,childOrderId]),'exact-root/source/customer/readOnly-proof') + validOutbox(proof.outboxProof), + 'exact-root/source/customer/readOnly-proof') + check(scope,'F40 新键重复签收完成Outbox数量与发布状态零漂移', + outboxCounts(proof.beforeReceiptReplayOutbox) === outboxCounts(proof.outboxProof),'before-after-receipt-replay-outbox') proof.finalStatus='VERIFIED' } else { proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING' @@ -417,7 +632,7 @@ async function runRootFulfillment(context) { proof.finalStatus = 'FAILED' proof.failedStage = proof.currentStage throw error - } finally { proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) } + } finally { preserveReceivingEvidence(proof,scope); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) } if (proof.finalStatus === 'VERIFIED') { const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false) if (failedSteps.length) proof.finalStatus = 'FAILED' diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index cb39c70ec..ef1cb940b 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -903,7 +903,7 @@ Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端 $unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md') Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界' Assert-Contains $unifiedCommunication '平台/客户/PDA' '统一认证访问域隔离' -Assert-Contains $unifiedCommunication '整体迁移尚未完成' '认证目标与落地状态分离' +Assert-Matches $unifiedCommunication '整体迁移(尚未完成|验收尚未关闭)' '认证目标与落地状态分离' Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影' Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付' -- 2.54.0 From 36a74eb58ecdfaa43c4e4ebcf7fdf7499d9acebe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 17:41:16 +0800 Subject: [PATCH 044/176] fix(firstleg): support platform scope port schedules --- deploy/sql/头程数据库统一增量-MySQL8.sql | 4 ++-- .../baiyue/firstleg/portschedule/dto/PortScheduleDtos.java | 2 +- .../firstleg/portschedule/service/PortScheduleService.java | 3 ++- .../src/main/resources/db/phase3-port-schedule-schema.sql | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3d7234d90..fa5ece41e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3721,7 +3721,7 @@ CREATE TABLE fl_port_schedule ( CONSTRAINT fk_port_schedule_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id), CONSTRAINT fk_port_schedule_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT chk_port_schedule_booking_cutoff CHECK (((booking_cutoff_at is null) or (etd_at is null) or (booking_cutoff_at <= etd_at))), - CONSTRAINT chk_port_schedule_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_port_schedule_customer CHECK ((customer_id >= 0)), CONSTRAINT chk_port_schedule_customs_cutoff CHECK (((customs_cutoff_at is null) or (etd_at is null) or (customs_cutoff_at <= etd_at))), CONSTRAINT chk_port_schedule_eta CHECK (((etd_at is null) or (eta_at is null) or (eta_at >= etd_at))), CONSTRAINT chk_port_schedule_expiry CHECK ((expires_at > observed_at)), @@ -5975,7 +5975,7 @@ CREATE TRIGGER trg_ocean_requirement_before_insert BEFORE INSERT ON fl_ocean_boo CREATE TRIGGER trg_ocean_requirement_before_update BEFORE UPDATE ON fl_ocean_booking_container_requirement FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$ CREATE TRIGGER trg_port_schedule_before_insert BEFORE INSERT ON fl_port_schedule FOR EACH ROW BEGIN - IF (SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> NEW.customer_id THEN + IF COALESCE((SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id),0) <> NEW.customer_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户与订舱客户不一致'; END IF; IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java index 3d496b8fd..f21ac4c86 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java @@ -23,7 +23,7 @@ public final class PortScheduleDtos { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } - public record ScheduleData(@NotNull @Positive Long customerId, + public record ScheduleData(@NotNull @Min(0) Long customerId, @NotNull @Positive Long oceanBookingId, @Positive Long containerBookingId, @NotBlank @Size(max = 24) String sourceType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java index e6d9d38a0..07376f204 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java @@ -102,7 +102,8 @@ public class PortScheduleService { private void validateLinks(ScheduleData data) { OceanBookingEntity booking = repository.oceanBooking(data.oceanBookingId()); if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在"); - if (!Objects.equals(booking.getCustomerId(), data.customerId())) throw badRequest("customerId与订舱客户不一致"); + long expectedCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId(); + if (expectedCustomerId != data.customerId()) throw badRequest("customerId与订舱客户不一致"); if (data.containerBookingId() != null) { if (repository.containerBooking(data.containerBookingId()) == null) { throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在"); diff --git a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql index 0c90d88d5..0d8d114da 100644 --- a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql +++ b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS fl_port_schedule ( KEY idx_port_schedule_expiry (expires_at,id), CONSTRAINT fk_port_schedule_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), CONSTRAINT fk_port_schedule_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), - CONSTRAINT chk_port_schedule_customer CHECK (customer_id > 0), + CONSTRAINT chk_port_schedule_customer CHECK (customer_id >= 0), CONSTRAINT chk_port_schedule_source_type CHECK (source_type IN ('MANUAL','CARRIER','TERMINAL','API')), CONSTRAINT chk_port_schedule_eta CHECK (etd_at IS NULL OR eta_at IS NULL OR eta_at >= etd_at), CONSTRAINT chk_port_schedule_expiry CHECK (expires_at > observed_at), @@ -93,7 +93,7 @@ DROP TRIGGER IF EXISTS trg_port_schedule_before_update; DELIMITER $$ CREATE TRIGGER trg_port_schedule_before_insert BEFORE INSERT ON fl_port_schedule FOR EACH ROW BEGIN - IF (SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> NEW.customer_id THEN + IF COALESCE((SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id),0) <> NEW.customer_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户与订舱客户不一致'; END IF; IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( -- 2.54.0 From 8d9d38ed4c26458d07f849f4bb407a17e5ad913c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 17:53:12 +0800 Subject: [PATCH 045/176] fix(firstleg): allow public sailing schedule dispatch fallback --- .../firstleg/portschedule/mapper/PortScheduleMapper.java | 5 +++-- .../service/PortScheduleDispatchGateContributor.java | 2 +- firstleg/src/main/resources/openapi/port-schedules-v1.yaml | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java index ba59385d7..7b2b4c5b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java @@ -34,9 +34,10 @@ public interface PortScheduleMapper extends BaseMapper { @Select(""" SELECT * FROM fl_port_schedule - WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId} + WHERE (customer_id=#{customerId} OR customer_id=0) AND ocean_booking_id=#{oceanBookingId} AND container_booking_id=#{containerBookingId} - ORDER BY observed_at DESC,updated_at DESC,id DESC LIMIT 1 + ORDER BY CASE WHEN customer_id=#{customerId} THEN 0 ELSE 1 END, + observed_at DESC,updated_at DESC,id DESC LIMIT 1 """) PortScheduleEntity latestForDispatch(@Param("customerId") long customerId, @Param("oceanBookingId") long oceanBookingId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java index 09b3fdcd5..307746f14 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java @@ -31,7 +31,7 @@ public class PortScheduleDispatchGateContributor implements OutboundDispatchGate PortScheduleEntity schedule = repository.latestForDispatch(context.tenantId(), context.oceanBookingId(), context.containerBookingId()); if (schedule == null) return gate(false, "MISSING"); - if (!java.util.Objects.equals(schedule.getCustomerId(), context.tenantId()) + if (!(java.util.Objects.equals(schedule.getCustomerId(), context.tenantId()) || schedule.getCustomerId() == 0L) || !java.util.Objects.equals(schedule.getOceanBookingId(), context.oceanBookingId()) || !java.util.Objects.equals(schedule.getContainerBookingId(), context.containerBookingId())) { return gate(false, "ASSOCIATION_MISMATCH"); diff --git a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml index d96d74796..d80cf6ae2 100644 --- a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml +++ b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml @@ -9,7 +9,7 @@ paths: summary: Query schedules by customer, ocean booking, allocated container or expiry x-permission: platform:firstleg:port-schedule:list parameters: - - {name: customerId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$'}} - {name: oceanBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: containerBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: expired, in: query, schema: {type: boolean}} @@ -83,7 +83,7 @@ components: - observedAt - expiresAt properties: - customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerId: {type: string, pattern: '^(0|[1-9][0-9]*)$'} oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$'} containerBookingId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'} sourceType: {type: string, enum: [MANUAL, CARRIER, TERMINAL, API]} -- 2.54.0 From 8a957790fcbf256aca9f05c30ef53397cf69d3c6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 17:58:36 +0800 Subject: [PATCH 046/176] test(firstleg): cover public schedule dispatch fallback --- .../repository/PortScheduleMapperContractTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java index 747b5582a..69897438a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java @@ -38,14 +38,15 @@ class PortScheduleMapperContractTest { } @Test - void dispatchGateReadsLatestExactCustomerBookingContainerSchedule() throws Exception { + void dispatchGateReadsLatestCustomerOrPublicBookingContainerSchedule() throws Exception { String sql = PortScheduleMapper.class .getMethod("latestForDispatch", long.class, long.class, long.class) .getAnnotation(Select.class).value()[0]; assertThat(sql) - .contains("customer_id=#{customerId}") + .contains("(customer_id=#{customerId} OR customer_id=0)") .contains("ocean_booking_id=#{oceanBookingId}") .contains("container_booking_id=#{containerBookingId}") - .contains("ORDER BY observed_at DESC,updated_at DESC,id DESC LIMIT 1"); + .contains("CASE WHEN customer_id=#{customerId} THEN 0 ELSE 1 END") + .contains("observed_at DESC,updated_at DESC,id DESC LIMIT 1"); } } -- 2.54.0 From a291bd6d45bb4e95108c227f8a1af1ccaa74bcff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 18:18:57 +0800 Subject: [PATCH 047/176] fix(firstleg): permit platform public port schedule scope --- .../controller/PlatformPortScheduleController.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java index 6b1c5c271..3f2738a75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java @@ -42,7 +42,6 @@ public class PlatformPortScheduleController { public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); - if (query.customerId() != null) FirstlegScopeGuard.platform(user, query.customerId()); return ApiResponse.success(service.page(query)); } @@ -69,7 +68,7 @@ public class PlatformPortScheduleController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { - FirstlegScopeGuard.platform(user, request.data().customerId()); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } @@ -81,7 +80,7 @@ public class PlatformPortScheduleController { @PathVariable @Positive long portScheduleId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { - FirstlegScopeGuard.platform(user, request.data().customerId()); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.update(portScheduleId, user.userId(), user.username(), key, request)); } } -- 2.54.0 From 8b9b135ab5f127f07ed3bd4b6b0ba52a26a590f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 10:34:00 +0800 Subject: [PATCH 048/176] fix(lastleg): align dispatch candidates with relabel reservation gate --- .../mapper/DispatchCandidateMapper.java | 6 ++-- .../DispatchCandidateMapperContractTest.java | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 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 3495e7769..aa160bdfe 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 @@ -121,9 +121,11 @@ public interface DispatchCandidateMapper { // A warehouse-transfer pallet may contain both relabel and non-relabel inventory. // Do not hide the whole pallet because one carton/SKU has an active relabel item; // the remaining available inventory must still be dispatchable. - "AND (ip.creation_mode!='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " + "WHERE rli.customer_id=ip.customer_id AND rli.source_inbound_order_id IS NOT NULL " + - "AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " + + "AND rpl.id IN (SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id) " + + "AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.plan_id,p.sequence_no" + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 4ff57ebcf..a3c404eb0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -5,11 +5,47 @@ import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; +import java.sql.DriverManager; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { + @Test + void availableAndLockedPalletsApplyTheSameActiveRelabelBoundary() throws Exception { + try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-relabel-boundary;MODE=MySQL")) { + var statement = connection.createStatement(); + statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT, customer_id VARCHAR, creation_mode VARCHAR)"); + statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT, plan_id BIGINT)"); + statement.execute("CREATE TABLE ll_order(id BIGINT)"); + statement.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT, order_id BIGINT)"); + statement.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT, line_id BIGINT)"); + statement.execute("CREATE TABLE ll_relabel_application(id BIGINT, customer_id VARCHAR, status VARCHAR)"); + statement.execute("CREATE TABLE ll_relabel_item(application_id BIGINT, customer_id VARCHAR, source_inbound_order_id BIGINT)"); + statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'100','AUTO'),(2,'100','AUTO'),(3,'100','WAREHOUSE_TRANSFER'),(4,'100','AUTO'),(5,'100','AUTO')"); + statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (11,1),(12,2),(13,3),(14,4),(15,5)"); + statement.execute("INSERT INTO ll_order VALUES (21),(22),(23),(24),(25),(26)"); + statement.execute("INSERT INTO ll_inbound_plan_line VALUES (31,21),(32,22),(33,23),(34,24),(35,25),(36,26)"); + statement.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (11,31),(12,32),(13,33),(14,34),(15,35),(15,36)"); + statement.execute("INSERT INTO ll_relabel_application VALUES (41,'100','PENDING_REVIEW'),(42,'100','COMPLETED'),(43,'100','PENDING_REVIEW'),(44,'200','PENDING_REVIEW'),(45,'100','PENDING_REVIEW')"); + statement.execute("INSERT INTO ll_relabel_item VALUES (41,'100',21),(42,'100',22),(43,'100',23),(44,'200',24),(45,'100',25)"); + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + String boundary = sql.substring(sql.lastIndexOf("AND (ip.creation_mode"), sql.lastIndexOf("GROUP BY")) + .replace("#{customerId}", "'100'"); + var result = statement.executeQuery("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " + + "JOIN ll_order o ON o.id=l.order_id WHERE 1=1 " + boundary + " ORDER BY p.id"); + var palletIds = new ArrayList(); + while (result.next()) palletIds.add(result.getLong(1)); + assertEquals(List.of(12L, 13L, 14L), palletIds, method.getName()); + } + } + } private static final List CONSTRUCTOR_ALIASES = List.of( "planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName", "planNo", "orderType", "volumeWeightKg", "grossWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", -- 2.54.0 From 0b34f99be584ee0ecb5e6ea92cd6acf3e43d3d31 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 11:16:15 +0800 Subject: [PATCH 049/176] fix(firstleg): validate public port schedule queries with zero scope --- .../portschedule/dto/PortScheduleDtos.java | 2 +- .../service/PortScheduleServiceTest.java | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java index f21ac4c86..e1fc557a7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java @@ -16,7 +16,7 @@ import java.util.List; public final class PortScheduleDtos { private PortScheduleDtos() {} - public record Query(@Positive Long customerId, @Positive Long oceanBookingId, + public record Query(@Min(0) Long customerId, @Positive Long oceanBookingId, @Positive Long containerBookingId, Boolean expired, @Size(max = 128) String keyword, @Min(1) long page, @Min(1) @Max(200) long size) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java index 7bfaae9aa..d0a794755 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import jakarta.validation.Validation; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -34,6 +35,16 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class PortScheduleServiceTest { + @Test + void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + assertThat(validator.validate(new com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query( + 0L, null, null, null, null, 1, 5))).isEmpty(); + assertThat(validator.validate(new com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query( + -1L, null, null, null, null, 1, 5))).anyMatch(row -> row.getPropertyPath().toString().equals("customerId")); + } + } @Mock private PortScheduleRepository repository; @InjectMocks private PortScheduleService service; @@ -79,6 +90,35 @@ class PortScheduleServiceTest { verify(repository, never()).insert(any(PortScheduleEntity.class)); } + @Test + void platformBookingRequiresZeroScopeAndRejectsArbitraryCustomerWithoutInsert() { + OceanBookingEntity platformBooking = new OceanBookingEntity(); + platformBooking.setId(10L); + when(repository.oceanBooking(10L)).thenReturn(platformBooking); + assertThatThrownBy(() -> service.create(7L, "admin", "wrong-platform-scope", + new CreateRequest(0, data(null, future(1), future(2)), null))) + .isInstanceOf(BizException.class).hasMessageContaining("customerId与订舱客户不一致"); + verify(repository, never()).insert(any(PortScheduleEntity.class)); + } + + @Test + void createsZeroScopeScheduleForPlatformBooking() { + OceanBookingEntity platformBooking = new OceanBookingEntity(); + platformBooking.setId(10L); + when(repository.oceanBooking(10L)).thenReturn(platformBooking); + when(repository.containerBooking(20L)).thenReturn(new ContainerBookingEntity()); + when(repository.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + ScheduleData d = data(20L, future(1), future(2)); + ScheduleData platformData = new ScheduleData(0L, d.oceanBookingId(), d.containerBookingId(), + d.sourceType(), d.sourceName(), d.sourceRef(), d.vesselName(), d.voyageNo(), + d.departurePortCode(), d.departurePortName(), d.destinationPortCode(), d.destinationPortName(), + d.etdAt(), d.etaAt(), d.bookingCutoffAt(), d.customsCutoffAt(), d.portCutoffAt(), + d.observedAt(), d.expiresAt(), d.remark()); + Detail result = service.create(7L, "admin", "platform-scope-create", + new CreateRequest(0, platformData, "平台公共船期")); + assertThat(result.portSchedule().schedule().getCustomerId()).isZero(); + } + @Test void rejectsStaleUpdateWithoutMutation() { when(repository.find(1L)).thenReturn(schedule(3)); -- 2.54.0 From 2d73ce9f3776a35d7099e1ca2bef8c626d3467bf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 13:36:39 +0800 Subject: [PATCH 050/176] fix(firstleg): preserve confirmed loading history in departure gates --- .../service/OutboundDispatchService.java | 19 +++-- .../dispatch/OutboundDispatchServiceTest.java | 69 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 70cd29af3..5c0e515b7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -163,13 +163,13 @@ public class OutboundDispatchService { List inboundDtos = new ArrayList<>(); List states = new ArrayList<>(); for (OutboundDispatchReadMapper.Binding binding : bindings) { - GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + GateResult gate = gate(binding, state, pick, ocean, evidence, containerContributorGates); inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), id(pick), status(pick))); - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), - binding.containerBookingId()); if (state != null) states.add(state); } StateView visible = visibleStateForRead(states, bindings.size()); @@ -180,14 +180,21 @@ public class OutboundDispatchService { visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } - private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, + private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchEntity dispatch, + OutboundDispatchReadMapper.IdStatus pick, OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, List containerContributorGates) { List items = new ArrayList<>(); items.add(item("INBOUND_NOT_HELD", "入库单未扣货", !Boolean.TRUE.equals(binding.holdFlag()), Boolean.TRUE.equals(binding.holdFlag()) ? "HELD" : "RELEASED", "RELEASED")); - items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), - binding.inboundStatus(), "LOADED")); + // Later delivery progress does not erase this binding's confirmed departure. + // Never infer loading completion from a later inbound status alone. + boolean confirmedLoadedHistory = dispatch != null && dispatch.getConfirmedAt() != null + && OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(dispatch.getStatus()) + && ("DELIVERED".equals(binding.inboundStatus()) || "COMPLETED".equals(binding.inboundStatus())); + items.add(item("INBOUND_LOADED", "入库单装柜完成", + "LOADED".equals(binding.inboundStatus()) || confirmedLoadedHistory, + binding.inboundStatus(), confirmedLoadedHistory ? "已确认离仓,装柜历史已保留" : "LOADED")); items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()), binding.loadingPlanStatus(), "COMPLETED")); items.add(item("PDA_PICK_COMPLETED", "PDA柜次拣货完成", pick != null && "COMPLETED".equals(pick.status()), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 9d4d38582..cce6c5889 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -11,6 +11,9 @@ import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; 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 java.time.LocalDateTime; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -31,6 +34,72 @@ class OutboundDispatchServiceTest { when(repository.timeline(any(), any(), any())).thenReturn(List.of()); } + @ParameterizedTest + @ValueSource(strings = {"DELIVERED", "COMPLETED"}) + void confirmedDepartureKeepsLoadedGateWhenInboundAdvances(String inboundStatus) { + stubContainer(List.of(bindingWithStatus(11L, inboundStatus))); + OutboundDispatchEntity confirmed = state(31L, 11L, "WAITING_FOR_LOADING", 1); + confirmed.setConfirmedAt(LocalDateTime.of(2026, 10, 1, 12, 0)); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(confirmed); + Detail detail = serviceWithoutCarrier().detail(22L); + assertThat(detail.aggregate().gate().passed()).isTrue(); + assertThat(detail.aggregate().inbounds().get(0).inboundStatus()).isEqualTo(inboundStatus); + assertThat(detail.aggregate().gate().inbounds().get(0).gate().items()) + .filteredOn(item -> item.code().equals("INBOUND_LOADED")) + .singleElement().satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo(inboundStatus); + assertThat(item.requirement()).contains("已确认离仓"); + }); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + } + + @ParameterizedTest + @ValueSource(strings = {"DELIVERED", "COMPLETED"}) + void laterInboundStateWithoutConfirmedDepartureCannotPassOrConfirm(String inboundStatus) { + var bindings = List.of(bindingWithStatus(11L, inboundStatus)); + stubContainer(bindings); + when(repository.lockBindings(22L)).thenReturn(bindings); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("later-unconfirmed"), anyString())).thenReturn(true); + var service = serviceWithoutCarrier(); + assertThat(service.checkGate(22L).passed()).isFalse(); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "later-unconfirmed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("门禁"); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + } + + @Test + void reversedDepartureAndMissingConfirmationTimeDoNotProveLoadedHistory() { + stubContainer(List.of(bindingWithStatus(11L, "DELIVERED"))); + var state = state(31L, 11L, "READY_FOR_DISPATCH", 2); + state.setConfirmedAt(LocalDateTime.of(2026, 10, 1, 12, 0)); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + assertThat(serviceWithoutCarrier().checkGate(22L).passed()).isFalse(); + state.setStatus("WAITING_FOR_LOADING"); + state.setConfirmedAt(null); + assertThat(serviceWithoutCarrier().checkGate(22L).passed()).isFalse(); + } + + @Test + void confirmedDepartureDoesNotBypassLoadingOrOtherAuthoritativeGates() { + var confirmed = state(31L, 11L, "WAITING_FOR_LOADING", 1); + confirmed.setConfirmedAt(LocalDateTime.of(2026, 10, 1, 12, 0)); + stubContainer(List.of(bindingWithStatus(11L, "LOADING"))); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(confirmed); + assertThat(serviceWithoutCarrier().checkGate(22L).passed()).isFalse(); + stubContainer(List.of(bindingWithStatus(11L, "DELIVERED"))); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + var gate = serviceWithoutCarrier().checkGate(22L); + assertThat(gate.passed()).isFalse(); + assertThat(gate.inbounds().get(0).gate().items()).filteredOn(item -> item.code().equals("LOADING_EVIDENCE_COMPLETE")) + .extracting(GateItem::passed).containsExactly(false); + } + + private static OutboundDispatchReadMapper.Binding bindingWithStatus(long inboundId, String status) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, "I-" + inboundId, 7L, 8L, "客户A", + status, false, 22L, "CAB-22", "C-22", 44L, "COMPLETED"); + } + @Test void containerEvidenceFailureFailsEveryBindingConsistently() { OutboundDispatchService service = serviceWithoutCarrier(); -- 2.54.0 From f532e5ee7e556835cb9f91244a05d438e8229012 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 14:44:51 +0800 Subject: [PATCH 051/176] fix(orders): filter tax mode before unified order pagination --- .../firstleg/dto/UnifiedInboundQuery.java | 4 +- .../repository/InboundOrderRepository.java | 1 + .../service/UnifiedInboundQueryService.java | 4 + .../main/resources/openapi/firstleg-v1.yaml | 1 + ...alInboundOrderReferenceControllerTest.java | 2 +- .../InboundOrderRepositoryTest.java | 2 +- .../UnifiedInboundTaxModePaginationTest.java | 82 +++++++++++++++++++ .../UnifiedInboundQueryServiceTest.java | 14 +++- .../client/FirstlegUnifiedOrderClient.java | 2 +- .../baiyue/order/dto/UnifiedOrderQuery.java | 4 +- .../order/repository/OrderRepository.java | 1 + ...icWarehouseOrderExportProviderService.java | 2 +- .../service/UnifiedOrderQueryService.java | 5 +- .../src/main/resources/openapi/order-v1.yaml | 8 ++ .../UnifiedOrderTaxModePaginationTest.java | 82 +++++++++++++++++++ .../service/UnifiedOrderQueryServiceTest.java | 51 ++++++++++-- 16 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java create mode 100644 order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index dfa3fdff9..ccea8652c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -35,7 +35,9 @@ public record UnifiedInboundQuery( LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, - @Min(1) @Max(200) long size + @Min(1) @Max(200) long size, + @Schema(allowableValues = {"TAX_REFUND", "BUY_DECLARATION"}) + @jakarta.validation.constraints.Pattern(regexp = "TAX_REFUND|BUY_DECLARATION") String taxMode ) { public UnifiedInboundQuery { if (page == 0) page = 1; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 00ea515d7..cd38a4284 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -118,6 +118,7 @@ public class InboundOrderRepository { public Page pageUnifiedEntities(UnifiedInboundQuery query, List processStatuses) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId()) + .eq(query.taxMode() != null, InboundOrderEntity::getTaxMode, query.taxMode()) .in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses) .like(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo()) .like(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode, query.inboundCode()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 7a6787457..e4848fdd0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -222,6 +222,10 @@ public class UnifiedInboundQueryService { } private void validate(UnifiedInboundQuery query) { + if (query.taxMode() != null && !java.util.Set.of("TAX_REFUND", "BUY_DECLARATION").contains(query.taxMode())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "taxMode仅支持TAX_REFUND或BUY_DECLARATION"); + } if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) ) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 026d8a674..d70822b29 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1557,6 +1557,7 @@ components: UnifiedInboundQuery: type: object properties: + taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION], nullable: true, description: 按税务模式在数据库计数和分页前过滤;未传不限制} tenantId: {type: integer, format: int64, nullable: true} keyword: {type: string, maxLength: 64, nullable: true} orderNo: {type: string, maxLength: 64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 63985272b..59a083a37 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -79,7 +79,7 @@ class InternalInboundOrderReferenceControllerTest { UnifiedInboundQuery request = new UnifiedInboundQuery( 10001L, null, null, "WAIT_SPLIT", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, 1, 20); + null, null, 1, 20, null); when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); assertThat(controller.unifiedPage("internal-token", request).data().records()).isEmpty(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index 99ef11f61..2199be89a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -57,7 +57,7 @@ class InboundOrderRepositoryTest { 10001L, null, null, "LOADING", 30001L, 40001L, 50001L, "RK-", "APPROVED", LocalDate.of(2026, 8, 6), "APPROVED", "美森", "MSCU", "CAB", LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", - List.of(77L, 78L), null, null, 1, 20); + List.of(77L, 78L), null, null, 1, 20, null); repository.pageUnifiedEntities(query, List.of("LOADING")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java new file mode 100644 index 000000000..a6d8dcd1c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java @@ -0,0 +1,82 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +/** Real mapper + pagination interceptor: tax filtering applies to COUNT and records before LIMIT. */ +class UnifiedInboundTaxModePaginationTest { + private InboundOrderRepository repository; + @BeforeEach + void setup() throws Exception { + var dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:tax-page-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var jdbc = new JdbcTemplate(dataSource); + var columns = new ArrayList(); + for (Class type = InboundOrderEntity.class; type != Object.class; type = type.getSuperclass()) { + for (var field : type.getDeclaredFields()) { + var annotation = field.getAnnotation(TableField.class); + if (Modifier.isStatic(field.getModifiers()) || annotation != null && !annotation.exist()) continue; + String name = field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + Class valueType = field.getType(); + String sqlType = valueType == Long.class || valueType == long.class ? "BIGINT" + : valueType == Integer.class || valueType == int.class || valueType == Boolean.class || valueType == boolean.class ? "INT" + : valueType == java.math.BigDecimal.class ? "DECIMAL(20,6)" + : valueType == java.time.LocalDateTime.class ? "TIMESTAMP" + : valueType == java.time.LocalDate.class ? "DATE" : "VARCHAR(2000)"; + columns.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" : "")); + } + } + jdbc.execute("CREATE TABLE fl_inbound_order (" + String.join(",", columns) + ")"); + for (int id = 1; id <= 7; id++) { + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,tax_mode,deleted,created_at) VALUES(?,?,?,?,?,?)", + id, id == 5 || id == 7 ? 22 : 11, "LOADED", id == 2 || id == 3 || id == 6 || id == 7 ? "TAX_REFUND" : "BUY_DECLARATION", + id == 3 ? 1 : 0, java.time.LocalDateTime.of(2026, 10, 2, 12, 0)); + } + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); config.addMapper(InboundOrderMapper.class); + var pagination = new MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(config); factory.setPlugins(pagination); + var session = new SqlSessionTemplate(factory.getObject()); + repository = new InboundOrderRepository(); + ReflectionTestUtils.setField(repository, "orders", session.getMapper(InboundOrderMapper.class)); + } + @Test + void taxModeFiltersCountsAndPagesWithoutLeakingAnotherCustomerOrDeletedRows() { + assertPage("TAX_REFUND", 1, 2, 6); + assertPage("TAX_REFUND", 2, 2, 2); + assertPage("BUY_DECLARATION", 1, 2, 4); + assertPage("BUY_DECLARATION", 2, 2, 1); + assertPage(null, 1, 4, 6); + assertPage(null, 2, 4, 4); + } + private void assertPage(String taxMode, long page, long total, long expectedId) { + var query = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, page, 1, taxMode); + var result = repository.pageUnifiedEntities(query, List.of("LOADED")); + assertThat(result.getTotal()).isEqualTo(total); + assertThat(result.getRecords()).singleElement().satisfies(row -> { + assertThat(row.getId()).isEqualTo(expectedId); + assertThat(row.getTenantId()).isEqualTo(11L); + if (taxMode != null) assertThat(row.getTaxMode()).isEqualTo(taxMode); + }); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index 6bcd6c210..e67875c2f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -24,6 +24,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class UnifiedInboundQueryServiceTest { + @ParameterizedTest + @ValueSource(strings = {"", " ", "tax_refund", "TAX_REFUND ", "OTHER", "TAX_INCLUDED"}) + void illegalTaxModeFailsBeforeRepository(String taxMode) { + var repository = mock(InboundOrderRepository.class); + var service = new UnifiedInboundQueryService(); + ReflectionTestUtils.setField(service, "inbounds", repository); + var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, 1, 20, taxMode); + assertThatThrownBy(() -> service.page(query)).isInstanceOf(BizException.class).hasMessageContaining("taxMode"); + verifyNoInteractions(repository); + } + @Test void mapsAllCompatibleInternalStatesToProductMainStatuses() { assertThat(UnifiedInboundQueryService.mainStatus("WAIT_MEASURE")).isEqualTo("WAIT_MEASURE"); @@ -121,7 +133,7 @@ class UnifiedInboundQueryServiceTest { UnifiedInboundQuery query = new UnifiedInboundQuery( null, null, null, null, null, null, null, null, null, null, "UNKNOWN", null, null, null, null, null, null, "INVALID", - null, null, null, 1, 20); + null, null, null, 1, 20, null); assertThatThrownBy(() -> new UnifiedInboundQueryService().page(query)) .isInstanceOf(BizException.class); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 87ea63aec..0faf2484f 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -34,7 +34,7 @@ public interface FirstlegUnifiedOrderClient { String feeReviewStatus, String channelName, String containerNo, String cabinetNo, LocalDate sailingDate, Boolean loaded, Boolean fullContainer, String measurementReviewStatus, List candidateOrderIds, - LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size) {} + LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size, String taxMode) {} record BatchRequest(Long tenantId, List orderIds) {} record DetailRequest(long tenantId, long orderId) {} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index c052e646d..39fd95ede 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -33,7 +33,9 @@ public record UnifiedOrderQuery( LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, - @Min(1) @Max(200) long size + @Min(1) @Max(200) long size, + @Schema(allowableValues = {"TAX_REFUND", "BUY_DECLARATION"}) + @jakarta.validation.constraints.Pattern(regexp = "TAX_REFUND|BUY_DECLARATION") String taxMode ) { public UnifiedOrderQuery { if (page == 0) page = 1; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 34f5670ad..d329f517b 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -301,6 +301,7 @@ public class OrderRepository { List statuses) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(tenantId != null, OrderEntity::getTenantId, tenantId) + .eq(query.taxMode() != null, OrderEntity::getTaxMode, query.taxMode()) .in(OrderEntity::getStatus, statuses) .like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo()) .like(StringUtils.hasText(query.inboundCode()), OrderEntity::getInboundCode, query.inboundCode()) diff --git a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java index 0abda5e62..7283fc5c2 100644 --- a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java +++ b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java @@ -282,7 +282,7 @@ public class DomesticWarehouseOrderExportProviderService { string("inboundCode"), string("internalOrderStatus"), date("expectedArrivalDate"), string("feeReviewStatus"), string("channelName"), string("containerNo"), string("cabinetNo"), date("sailingDate"), bool("loaded"), bool("fullContainer"), string("measurementReviewStatus"), - string("fbaCode"), dateTime("createdFrom"), dateTime("createdTo"), page, limit); + string("fbaCode"), dateTime("createdFrom"), dateTime("createdTo"), page, limit, null); } private String string(String key) { Object value = parameters.get(key); return value == null || String.valueOf(value).isBlank() ? null : String.valueOf(value).trim(); } private Long number(String key) { String value = string(key); if (value == null) return null; try { long parsed=Long.parseLong(value); if(parsed<=0)throw new NumberFormatException(); return parsed; } catch(NumberFormatException ex){throw new BizException(ResultCode.VALIDATION_FAILED,key+" 必须为正整数字符串");} } diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 2b9c264ca..c266bc268 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -159,7 +159,7 @@ public class UnifiedOrderQueryService { query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(), - query.page(), query.size()); + query.page(), query.size(), query.taxMode()); PageResult source = require( call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); List summaries = validateSummaries( @@ -555,6 +555,9 @@ public class UnifiedOrderQueryService { } private void validate(UnifiedOrderQuery query) { + if (query.taxMode() != null && !Set.of("TAX_REFUND", "BUY_DECLARATION").contains(query.taxMode())) { + throw new BizException(ResultCode.BAD_REQUEST, "taxMode仅支持TAX_REFUND或BUY_DECLARATION"); + } if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) ) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c1ea54b99..b5a7fdad2 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -329,6 +329,7 @@ paths: relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。 coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。 parameters: + - $ref: '#/components/parameters/UnifiedTaxMode' - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - $ref: '#/components/parameters/UnifiedKeyword' - $ref: '#/components/parameters/UnifiedOrderNo' @@ -444,6 +445,7 @@ paths: 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 parameters: + - $ref: '#/components/parameters/UnifiedTaxMode' - $ref: '#/components/parameters/UnifiedKeyword' - $ref: '#/components/parameters/UnifiedOrderNo' - $ref: '#/components/parameters/UnifiedMainStatus' @@ -1488,6 +1490,12 @@ components: in: query required: false schema: {type: string, maxLength: 64} + UnifiedTaxMode: + name: taxMode + in: query + required: false + description: 严格按税务模式在数据库计数和分页前过滤;未传查询全部税务模式,空串或其他值拒绝 + schema: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]} UnifiedOrderNo: name: orderNo in: query diff --git a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java new file mode 100644 index 000000000..7d53ae1ac --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java @@ -0,0 +1,82 @@ +package com.baiyue.order.repository; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.dto.UnifiedOrderQuery; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +/** Real mapper + pagination interceptor: tax filtering applies to COUNT and records before LIMIT. */ +class UnifiedOrderTaxModePaginationTest { + private OrderRepository repository; + @BeforeEach + void setup() throws Exception { + var dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:tax-page-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var jdbc = new JdbcTemplate(dataSource); + var columns = new ArrayList(); + for (Class type = OrderEntity.class; type != Object.class; type = type.getSuperclass()) { + for (var field : type.getDeclaredFields()) { + var annotation = field.getAnnotation(TableField.class); + if (Modifier.isStatic(field.getModifiers()) || annotation != null && !annotation.exist()) continue; + String name = field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + Class valueType = field.getType(); + String sqlType = valueType == Long.class || valueType == long.class ? "BIGINT" + : valueType == Integer.class || valueType == int.class || valueType == Boolean.class || valueType == boolean.class ? "INT" + : valueType == java.math.BigDecimal.class ? "DECIMAL(20,6)" + : valueType == java.time.LocalDateTime.class ? "TIMESTAMP" + : valueType == java.time.LocalDate.class ? "DATE" : "VARCHAR(2000)"; + columns.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" : "")); + } + } + jdbc.execute("CREATE TABLE ord_order (" + String.join(",", columns) + ")"); + for (int id = 1; id <= 7; id++) { + jdbc.update("INSERT INTO ord_order(id,tenant_id,status,tax_mode,deleted,created_at) VALUES(?,?,?,?,?,?)", + id, id == 5 || id == 7 ? 22 : 11, "APPROVED", id == 2 || id == 3 || id == 6 || id == 7 ? "TAX_REFUND" : "BUY_DECLARATION", + id == 3 ? 1 : 0, java.time.LocalDateTime.of(2026, 10, 2, 12, 0)); + } + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); config.addMapper(OrderMapper.class); + var pagination = new MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(config); factory.setPlugins(pagination); + var session = new SqlSessionTemplate(factory.getObject()); + repository = new OrderRepository(); + ReflectionTestUtils.setField(repository, "orderMapper", session.getMapper(OrderMapper.class)); + } + @Test + void taxModeFiltersCountsAndPagesWithoutLeakingAnotherCustomerOrDeletedRows() { + assertPage("TAX_REFUND", 1, 2, 6); + assertPage("TAX_REFUND", 2, 2, 2); + assertPage("BUY_DECLARATION", 1, 2, 4); + assertPage("BUY_DECLARATION", 2, 2, 1); + assertPage(null, 1, 4, 6); + assertPage(null, 2, 4, 4); + } + private void assertPage(String taxMode, long page, long total, long expectedId) { + var query = new UnifiedOrderQuery(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, page, 1, taxMode); + var result = repository.unifiedPage(11L, query, List.of(com.baiyue.order.entity.OrderStatus.APPROVED)); + assertThat(result.total()).isEqualTo(total); + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.getId()).isEqualTo(expectedId); + assertThat(row.getTenantId()).isEqualTo(11L); + if (taxMode != null) assertThat(row.getTaxMode()).isEqualTo(taxMode); + }); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index f65377b3d..aaf0e2840 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -292,7 +292,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = new UnifiedOrderQuery( null, null, "PENDING_REVIEW", null, null, null, "RK-77", null, null, null, null, null, null, null, null, null, null, - null, null, null, 1, 20); + null, null, null, 1, 20, null); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -512,7 +512,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = new UnifiedOrderQuery( null, null, null, null, null, null, "RK-77", null, null, null, null, null, null, null, null, null, null, null, - null, null, 1, 20); + null, null, 1, 20, null); when(orders.unifiedPage(eq(10001L), eq(query), any())) .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -785,7 +785,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = new UnifiedOrderQuery( null, null, "INBOUNDED", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, 4, 10); + null, null, 4, 10, null); var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().currentHoldReason()).thenReturn(null); @@ -909,7 +909,7 @@ class UnifiedOrderQueryServiceTest { statuses.forEach(status -> service.page(null, new UnifiedOrderQuery( "CC-2026", "FL-77", status, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, 3, 20))); + null, null, 3, 20, null))); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); @@ -931,7 +931,7 @@ class UnifiedOrderQueryServiceTest { null, null, "LOADING", 30001L, 11L, 22L, "RK-77", "APPROVED", LocalDate.of(2026, 8, 6), "APPROVED", "美森", "MSCU", "CAB-1", LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1", - null, null, 1, 20); + null, null, 1, 20, null); when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L)); when(firstleg.page(eq("internal-token"), any())) .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0))); @@ -959,7 +959,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = new UnifiedOrderQuery( null, null, null, null, null, null, null, null, null, "UNKNOWN", null, null, null, null, null, null, "INVALID", null, - null, null, 1, 20); + null, null, 1, 20, null); assertThatThrownBy(() -> service.page(10001L, query)) .isInstanceOf(BizException.class); @@ -970,7 +970,44 @@ class UnifiedOrderQueryServiceTest { private UnifiedOrderQuery query(String status) { return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, 1, 20); + null, null, 1, 20, null); + } + + private UnifiedOrderQuery taxQuery(String status, String taxMode) { + return new UnifiedOrderQuery(null, null, status, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + null, null, 2, 1, taxMode); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.NullSource + @org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"}) + void taxModeRetainsTenantAndPaginationOnBothQueryBranches(String taxMode) { + var direct = taxQuery(null, taxMode); + when(orders.unifiedPage(eq(10001L), eq(direct), any())) + .thenReturn(new PageResult<>(List.of(), 2, 1, 0, 0)); + service.page(10001L, direct); + verify(orders).unifiedPage(eq(10001L), eq(direct), any()); + org.mockito.Mockito.verify(firstleg, org.mockito.Mockito.never()).page(any(), any()); + when(firstleg.page(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 2, 1, 0, 0))); + service.page(10001L, taxQuery("LOADED", taxMode)); + ArgumentCaptor captured = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); + verify(firstleg).page(eq("internal-token"), captured.capture()); + assertThat(captured.getValue().taxMode()).isEqualTo(taxMode); + assertThat(captured.getValue().tenantId()).isEqualTo(10001L); + assertThat(captured.getValue().page()).isEqualTo(2); + assertThat(captured.getValue().size()).isEqualTo(1); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"", " ", "tax_refund", "TAX_REFUND ", "OTHER", "TAX_INCLUDED"}) + void illegalTaxModeFailsBeforeEitherRepositoryOrFirstleg(String taxMode) { + for (String status : List.of("PENDING_REVIEW", "LOADED")) { + assertThatThrownBy(() -> service.page(10001L, taxQuery(status, taxMode))) + .isInstanceOf(BizException.class).hasMessageContaining("taxMode"); + } + org.mockito.Mockito.verifyNoInteractions(orders, firstleg); } private void assertServiceUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { -- 2.54.0 From 195446e532580afc79cfc01ae20f05e19a81fe12 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 15:03:09 +0800 Subject: [PATCH 052/176] fix(order): preserve tax mode in remainder child orders --- .../service/OrderFulfillmentSplitService.java | 1 + .../UnifiedOrderTaxModePaginationTest.java | 46 +++++++++++++++++-- .../OrderFulfillmentSplitServiceTest.java | 19 ++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index e5fffe608..fdc9520ed 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -142,6 +142,7 @@ public class OrderFulfillmentSplitService { child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode()); child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType()); child.setFulfillmentType(root.getFulfillmentType()); + child.setTaxMode(root.getTaxMode()); child.setInboundCode(remainderInboundCode(root, sequence)); child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId); if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败"); diff --git a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java index 7d53ae1ac..756055ee9 100644 --- a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java +++ b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java @@ -24,11 +24,13 @@ import static org.assertj.core.api.Assertions.assertThat; /** Real mapper + pagination interceptor: tax filtering applies to COUNT and records before LIMIT. */ class UnifiedOrderTaxModePaginationTest { private OrderRepository repository; + private JdbcTemplate jdbc; + private OrderMapper mapper; @BeforeEach void setup() throws Exception { var dataSource = new JdbcDataSource(); dataSource.setURL("jdbc:h2:mem:tax-page-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); - var jdbc = new JdbcTemplate(dataSource); + jdbc = new JdbcTemplate(dataSource); var columns = new ArrayList(); for (Class type = OrderEntity.class; type != Object.class; type = type.getSuperclass()) { for (var field : type.getDeclaredFields()) { @@ -41,7 +43,9 @@ class UnifiedOrderTaxModePaginationTest { : valueType == java.math.BigDecimal.class ? "DECIMAL(20,6)" : valueType == java.time.LocalDateTime.class ? "TIMESTAMP" : valueType == java.time.LocalDate.class ? "DATE" : "VARCHAR(2000)"; - columns.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" : "")); + columns.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" + : name.equals("deleted") || name.equals("version") ? " DEFAULT 0" + : name.equals("created_at") ? " DEFAULT CURRENT_TIMESTAMP" : "")); } } jdbc.execute("CREATE TABLE ord_order (" + String.join(",", columns) + ")"); @@ -58,7 +62,8 @@ class UnifiedOrderTaxModePaginationTest { factory.setDataSource(dataSource); factory.setConfiguration(config); factory.setPlugins(pagination); var session = new SqlSessionTemplate(factory.getObject()); repository = new OrderRepository(); - ReflectionTestUtils.setField(repository, "orderMapper", session.getMapper(OrderMapper.class)); + mapper = session.getMapper(OrderMapper.class); + ReflectionTestUtils.setField(repository, "orderMapper", mapper); } @Test void taxModeFiltersCountsAndPagesWithoutLeakingAnotherCustomerOrDeletedRows() { @@ -69,6 +74,41 @@ class UnifiedOrderTaxModePaginationTest { assertPage(null, 1, 4, 6); assertPage(null, 2, 4, 4); } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"}) + void realSplitChildIsPersistedAndIncludedInTaxFilteredParentChildPages(String taxMode) { + jdbc.update("UPDATE ord_order SET tax_mode=?,order_role='MASTER',order_no='TAX-PARENT',inbound_code='TAX-INBOUND',version=0 WHERE id=6", taxMode); + var root = mapper.findOwned(11L, 6L); + var ledger = org.mockito.Mockito.mock(com.baiyue.order.mapper.OrderFulfillmentSplitMapper.class); + org.mockito.Mockito.when(ledger.findByIdempotency(11L, 6L, "tax-split")).thenReturn(List.of()); + org.mockito.Mockito.when(ledger.lockOrder(11L, 6L)).thenReturn(root); + org.mockito.Mockito.when(ledger.insert(org.mockito.ArgumentMatchers.any(com.baiyue.order.entity.OrderFulfillmentSplitEntity.class))).thenReturn(1); + org.mockito.Mockito.when(ledger.incrementParentVersion(11L, 6L, 0, 9L)).thenReturn(1); + var splitter = new com.baiyue.order.service.OrderFulfillmentSplitService(); + ReflectionTestUtils.setField(splitter, "splits", ledger); + ReflectionTestUtils.setField(splitter, "orders", mapper); + ReflectionTestUtils.setField(splitter, "json", new com.fasterxml.jackson.databind.ObjectMapper()); + var result = splitter.split(new com.baiyue.order.dto.OrderSplitDtos.CreateRequest( + 11L, 6L, 6L, 701L, 801L, 2, 1, 1, 0, "tax-split", "op-tax-split", + List.of(new com.baiyue.order.dto.OrderSplitDtos.QuantityLine(99L, 2)), "operator"), 9L); + long childId = result.remainderChild().orderId(); + assertThat(mapper.findOwned(11L, childId).getTaxMode()).isEqualTo(taxMode); + var pageIds = new ArrayList(); + for (long page = 1; page <= 2; page++) { + var query = new UnifiedOrderQuery(null, "TAX-PARENT", null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, page, 1, taxMode); + var filtered = repository.unifiedPage(11L, query, List.of(com.baiyue.order.entity.OrderStatus.APPROVED)); + assertThat(filtered.total()).isEqualTo(2); + assertThat(filtered.records()).singleElement().satisfies(row -> { + pageIds.add(row.getId()); + assertThat(row.getTaxMode()).isEqualTo(taxMode); + assertThat(row.getTenantId()).isEqualTo(11L); + }); + } + assertThat(pageIds).containsExactlyInAnyOrder(childId, 6L); + assertThat(mapper.findOwned(22L, childId)).isNull(); + } private void assertPage(String taxMode, long page, long total, long expectedId) { var query = new UnifiedOrderQuery(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, page, 1, taxMode); var result = repository.unifiedPage(11L, query, List.of(com.baiyue.order.entity.OrderStatus.APPROVED)); diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java index 7636a6b38..3b5e7a483 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java @@ -68,6 +68,25 @@ class OrderFulfillmentSplitServiceTest { verify(splits).incrementParentVersion(10, 100, 3, 9); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"}) + void remainderInheritsRootTaxModeThroughRealSplitCommand(String taxMode) { + OrderEntity parent = parent(); + parent.setTaxMode(taxMode); + when(splits.findByIdempotency(10, 100, "tax-child")).thenReturn(List.of()); + when(splits.lockOrder(10, 100)).thenReturn(parent); + when(orders.insert(any(OrderEntity.class))).thenReturn(1); + when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1); + when(splits.incrementParentVersion(10, 100, 3, 9)).thenReturn(1); + var result = service.split(request(100L, 20, 8, 12, "tax-child"), 9); + ArgumentCaptor child = ArgumentCaptor.forClass(OrderEntity.class); + verify(orders).insert(child.capture()); + assertThat(child.getValue().getId()).isEqualTo(result.remainderChild().orderId()); + assertThat(child.getValue().getTaxMode()).isEqualTo(taxMode); + assertThat(child.getValue().getTenantId()).isEqualTo(parent.getTenantId()); + assertThat(child.getValue().getRootOrderId()).isEqualTo(parent.getId()); + } + @Test void rejectsConcurrentParentVersionChangeWithoutPublishingNextVersion() { OrderEntity parent = parent(); -- 2.54.0 From e15bec30527711efdc38fe8127d80a51b2af77bd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 16:59:16 +0800 Subject: [PATCH 053/176] fix(lastleg): expose and validate individual return carton picks --- .../service/DeliveryTransferService.java | 64 +++++-- .../openapi/overseas-pda-operations-v1.yaml | 21 +-- .../ReturnOutboundPdaPersistenceTest.java | 165 ++++++++++++++++++ 3 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java 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 3bb1f180e..96001b314 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 @@ -420,9 +420,11 @@ public class DeliveryTransferService { outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0); outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); - OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); - item.setOutboundOrderId(outbound.getId()); item.setPackageCount(marks.size()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - item.setCartonMarks(String.join(",", marks)); item.setInboundCode(request.returnNo().trim()); item.setCreatedAt(now); outboundPallets.insert(item); + for (ReturnInventoryCarton carton : locked) { + 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()); item.setCreatedAt(now); outboundPallets.insert(item); + } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); return taskResult(outbound, task); @@ -646,7 +648,12 @@ public class DeliveryTransferService { || !request.packages().equals(replay.getPackages())) { throw conflict("Idempotency key was reused with a different outbound pick request"); } - return taskResult(requireOutbound(task), task); + OutboundOrder replayOutbound = requireOutbound(task); + if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())) { + OutboundPalletItem replayItem = outboundPallets.selectById(replay.getOutboundPalletItemId()); + requireReturnScan(replayOutbound, replayItem, request); + } + return taskResult(replayOutbound, task); } OutboundOrder existingOutbound = requireOutbound(task); OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo()); @@ -660,17 +667,13 @@ public class DeliveryTransferService { || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { - if (request.inventoryPositionId() != null) throw conflict("退货出库无需扫描库存仓位"); - List marks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")) - .map(String::trim).filter(StringUtils::hasText).toList(); - if (request.packages() != marks.size() || value(item.getPickedPackageCount()) > 0) throw conflict("退货出库必须一次扫描本次箱唛且不可重复"); - for (String mark : marks) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); - if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId()) || !"AVAILABLE".equals(carton.getStatus())) { - throw conflict("退货箱唛不可出库:" + mark); - } - carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); - } + requireReturnScan(outbound, item, request); + if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱唛已完成拣货"); + ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks()); + requireReturnSource(outbound, item, carton); + if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks()); + carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); + if (returnCartons.updateById(carton) != 1) throw conflict("退货箱唛状态已变化,请重新核对"); OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); @@ -1037,7 +1040,8 @@ public class DeliveryTransferService { task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(), item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus(), - pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); + "RETURN_OUTBOUND".equals(outbound.getOutboundType()) ? returnPickPositions(outbound, task, item) + : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } @@ -1071,6 +1075,34 @@ public class DeliveryTransferService { throw notFound("Outbound or dispatch task not found in the bound warehouse"); } + private void requireReturnScan(OutboundOrder outbound, OutboundPalletItem item, DeliveryRequests.PdaPick request) { + if (item == null || !outbound.getId().equals(item.getOutboundOrderId()) + || !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在"); + if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1) + throw conflict("退货出库须逐箱扫描,无需库存仓位"); + if (request.cartonMarks() == null || request.cartonMarks().size() != 1 || request.cartonMarks().get(0) == null + || !Objects.equals(item.getCartonMarks(), request.cartonMarks().get(0).trim())) + throw conflict("请扫描当前退货出库明细指定的箱唛"); + } + + private void requireReturnSource(OutboundOrder outbound, OutboundPalletItem item, ReturnInventoryCarton carton) { + if (carton == null || !outbound.getCustomerId().equals(carton.getCustomerId()) + || !outbound.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !Objects.equals(item.getInboundCode(), carton.getSourceReturnNo()) + || !Objects.equals(item.getCartonMarks(), carton.getCartonMark())) + throw conflict("退货箱唛与本次出库的客户、仓库或来源退货单不一致"); + } + + private List returnPickPositions(OutboundOrder outbound, OutboundPdaTask task, + OutboundPalletItem item) { + ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks()); + requireReturnSource(outbound, item, carton); + boolean pending = Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus()) && value(item.getPickedPackageCount()) == 0; + long available = pending && "AVAILABLE".equals(carton.getStatus()) ? 1 : 0; + return List.of(new DeliveryResponses.PdaPickPosition(null, null, item.getCartonMarks(), item.getSkuCode(), + item.getInboundCode(), available, value(item.getPickedPackageCount()))); + } + private List pdaPickPositions(String customerId, String warehouseId, OutboundPalletItem item) { if (item.getWarehousePalletId() == null) return List.of(); 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 988750eaa..99cd5ad41 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 @@ -865,16 +865,16 @@ components: remark: { type: string, maxLength: 1000, nullable: true } OutboundPick: type: object - required: [outboundPalletItemId, inventoryPositionId, packages, idempotencyKey] + required: [outboundPalletItemId, packages, idempotencyKey] properties: outboundPalletItemId: { type: string, pattern: '^[0-9]+$' } - inventoryPositionId: { type: string, pattern: '^[0-9]+$' } + inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID } packages: { type: integer, format: int64, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } cartonMarks: type: array maxItems: 500 - description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 + description: 本次实际扫描的箱唛。退货出库每条明细对应一个真实退货箱,必须提交该箱的 cartonMark,packages 为 1;相同幂等键改传其他箱唛同样拒绝,执行时重新核对客户、仓库及来源退货单。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 items: { type: string, minLength: 1, maxLength: 255 } PdaOutboundTaskResponse: type: object @@ -901,25 +901,26 @@ components: type: array items: type: object - required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions] + required: [outboundPalletItemId, inboundCode, packageCount, pickedPackageCount, status, positions] properties: outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 } - palletNo: { type: string, description: Warehouse 权威实物托盘码 } - skuCode: { type: string } + palletNo: { type: string, nullable: true, description: Warehouse 权威实物托盘码;退货箱明细不伪造托盘码 } + skuCode: { type: string, nullable: true } inboundCode: { type: string } packageCount: { type: integer } pickedPackageCount: { type: integer } status: { type: string } positions: type: array + description: 普通出库为真实库存仓位;退货出库为已冻结真实箱的扫码行,inventoryPositionId 与 palletNo 为空,cartonMark 为扫码业务码、inboundCode 为退货单号,按箱计数。已拣或已完成时 availablePackages 为 0,详情查询不写库存。 items: type: object - required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages] + required: [inboundCode, availablePackages, pickedPackages] properties: - inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 } - palletNo: { type: string, description: Warehouse 权威实物托盘码 } + inventoryPositionId: { type: string, nullable: true, description: 普通库存的pick提交键;退货箱无仓位键,不得以其内部ID替代 } + palletNo: { type: string, nullable: true, description: Warehouse 权威实物托盘码;退货箱不提供伪造托盘号 } cartonMark: { type: string, nullable: true } - skuCode: { type: string } + skuCode: { type: string, nullable: true } inboundCode: { type: string } availablePackages: { type: integer, format: int64, minimum: 0 } pickedPackages: { type: integer, format: int64, minimum: 0 } 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 new file mode 100644 index 000000000..62f31bc9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.web.server.ResponseStatusException; +import java.lang.reflect.Modifier; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ReturnOutboundPdaPersistenceTest { + private JdbcTemplate jdbc; + private DeliveryTransferService service; + + @BeforeEach void setUp() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:return-pda-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + // Persist production entities through production MyBatis mappers, with real transactions. + for (Class entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class, + OutboundPdaPick.class, ReturnInventoryCarton.class)) { + String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " " + + (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP" + : field.getType() == java.math.BigDecimal.class ? "DECIMAL(20,6)" : "BIGINT") + + (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(",")); + jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"); + } + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + for (Class mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class, + OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class)) configuration.addMapper(mapper); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); + var session = new SqlSessionTemplate(factory.getObject()); + var target = new DeliveryTransferService(); + ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); + ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class)); + ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class)); + ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class)); + ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class)); + var numbers = mock(LastlegOrderNoGenerator.class); + when(numbers.next(eq("RETURN_OUTBOUND"), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2)); + ReflectionTestUtils.setField(target, "orderNoGenerator", numbers); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (DeliveryTransferService) proxy.getProxy(); + carton(1, "C1", "WH-1", "RT-1", "BOX-A"); carton(2, "C1", "WH-1", "RT-1", "BOX-B"); + carton(3, "C2", "WH-1", "RT-2", "OTHER-CUSTOMER"); carton(4, "C1", "WH-2", "RT-1", "OTHER-WAREHOUSE"); + } + private void carton(long id, String customer, String warehouse, String returnNo, String mark) { + jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_return_no,carton_mark,status,total_quantity) VALUES(?,?,?,?,?,'AVAILABLE',5)", id, customer, warehouse, returnNo, mark); + } + private com.baiyue.lastleg.delivery.dto.DeliveryResponses.PdaTask create(String key, String... marks) { + return service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1", List.of(marks), "fixture", key)); + } + @Test void singleExistingShapeHasRealCartonScanRowWithoutInventedPalletOrPosition() { + var task = create("single", "BOX-A"); + var detail = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals(1, detail.items().size()); + assertEquals(1, detail.items().get(0).positions().size()); + var row = detail.items().get(0).positions().get(0); + assertNull(row.inventoryPositionId()); assertNull(row.palletNo()); + assertEquals("BOX-A", row.cartonMark()); assertEquals("RT-1", row.inboundCode()); + assertEquals(1, row.availablePackages()); assertEquals(0, row.pickedPackages()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } + @Test void multiplePhysicalCartonsPersistSeparateIndependentlyScannableItems() { + var task = create("multi", "BOX-A", "BOX-B"); + var detail = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals(2, detail.items().size()); + assertTrue(detail.items().stream().allMatch(item -> item.packageCount() == 1 && item.positions().size() == 1)); + var a = detail.items().stream().filter(item -> item.positions().get(0).cartonMark().equals("BOX-A")).findFirst().orElseThrow(); + service.pdaPick("WH-1", "operator", task.taskNo(), new DeliveryRequests.PdaPick(a.outboundPalletItemId(), null, 1L, "pick-a", List.of("BOX-A"))); + var after = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals(1, after.items().stream().mapToInt(item -> item.pickedPackageCount()).sum()); + assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum()); + } + @Test void wrongScanAndWrongReplayPayloadLeaveStoredInventoryUnchanged() { + var task = create("scan", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "bad", List.of("BOX-B")))); + assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + var valid = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-A")); + service.pdaPick("WH-1", "operator", task.taskNo(), valid); + service.pdaPick("WH-1", "operator", task.taskNo(), valid); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-B")))); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } + @Test void customerWarehouseAndSourceReturnMismatchNeverAppearAsPickableRows() { + var task = create("scope", "BOX-A"); + assertThrows(ResponseStatusException.class, () -> service.pdaTaskDetail("WH-2", task.taskNo())); + for (String mark : List.of("OTHER-CUSTOMER", "OTHER-WAREHOUSE")) { + assertThrows(ResponseStatusException.class, () -> create("bad-" + mark, mark)); + } + for (String column : List.of("customer_id", "overseas_warehouse_id", "source_return_no")) { + String original = jdbc.queryForObject("SELECT " + column + " FROM ll_return_inventory_carton WHERE id=1", String.class); + jdbc.update("UPDATE ll_return_inventory_carton SET " + column + "='FOREIGN' WHERE id=1"); + assertThrows(ResponseStatusException.class, () -> service.pdaTaskDetail("WH-1", task.taskNo())); + jdbc.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=1", original); + } + } + @Test void completedTaskDetailIsReadOnlyAndContainsNoPickableStock() { + var task = create("complete", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick", List.of("BOX-A")); + service.pdaPick("WH-1", "operator", task.taskNo(), pick); + service.completePda("WH-1", task.taskNo(), new DeliveryRequests.PdaComplete("complete-key")); + for (int i = 0; i < 2; i++) { + var detail = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals("COMPLETED", detail.task().status()); + assertTrue(detail.items().stream().flatMap(row -> row.positions().stream()).allMatch(row -> row.availablePackages() == 0)); + } + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "new-key", List.of("BOX-A")))); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } + + @Test void sourceChangedAfterDetailReadCannotBeShippedByTheOldRequest() { + var task = create("source-change", "BOX-A"); + var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-OTHER' WHERE id=1"); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stale-source", List.of("BOX-A")))); + assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + assertEquals(0, jdbc.queryForObject("SELECT picked_package_count FROM ll_outbound_pallet_item", Integer.class)); + } + + @Test void failedPickJournalRollsBackCartonAndTaskThenAllowsAnExactRetry() { + var task = create("rollback", "BOX-A"); + var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "rollback-pick", List.of("BOX-A")); + jdbc.execute("ALTER TABLE ll_outbound_pda_pick ADD CONSTRAINT fail_pick CHECK(idempotency_key <> 'rollback-pick')"); + assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), request)); + assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + var unchanged = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals("PENDING", unchanged.task().status()); + assertEquals(1, unchanged.items().get(0).positions().get(0).availablePackages()); + jdbc.execute("ALTER TABLE ll_outbound_pda_pick DROP CONSTRAINT fail_pick"); + service.pdaPick("WH-1", "operator", task.taskNo(), request); + assertEquals("OUTBOUNDED", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } +} -- 2.54.0 From 4655090f76320269b04abf292fa91e67ba0a38db Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 17:47:11 +0800 Subject: [PATCH 054/176] fix(lastleg): account return carton outbound stock atomically --- deploy/sql/README.md | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 30 ++++- .../service/DeliveryTransferService.java | 8 +- .../ReturnInventoryOutboundMovement.java | 30 +++++ .../mapper/ReturnInventoryBalanceMapper.java | 12 ++ .../mapper/ReturnInventoryMovementMapper.java | 8 +- .../service/ReturnInventoryService.java | 53 ++++++++ .../src/main/resources/db/lastleg.sql | 28 ++++ .../ReturnOutboundPdaPersistenceTest.java | 123 +++++++++++++++++- .../initialization-baseline-contract.test.mjs | 13 +- 10 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0729767aa..b5600909e 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 135 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 136 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8f54c1129..18f0c47a5 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2258,6 +2258,34 @@ CREATE TABLE ll_relabel_status_history ( KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史'; +-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。 +CREATE TABLE ll_return_inventory_outbound_movement ( + id BIGINT NOT NULL COMMENT '退货出库SKU流水ID', + outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID', + carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码', + outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号', + sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量', + total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量', + available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量', + available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量', + cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code), + UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code), + KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at), + 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=1 AND total_after>=available_after) +) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水'; + CREATE TABLE ll_return_inventory_balance ( id bigint NOT NULL COMMENT '退货库存余额ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', @@ -2992,4 +3020,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 135 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 136 AS initialized_tables; 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 96001b314..0253b723d 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 @@ -109,6 +109,7 @@ public class DeliveryTransferService { @Resource private DropshipCartonSkuMapper dropshipCartonSkus; @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock; @Resource private InboundPlanPalletAllocationMapper planAllocations; @Resource private InboundPlanPalletMapper inboundPlanPallets; @Resource private WarehouseResourceGateway warehouseResources; @@ -672,9 +673,10 @@ public class DeliveryTransferService { ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks()); requireReturnSource(outbound, item, carton); if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks()); - carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); - if (returnCartons.updateById(carton) != 1) throw conflict("退货箱唛状态已变化,请重新核对"); - OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + long outboundPickId = IdWorker.getId(); + returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), + outbound.getOutboundNo(), outboundPickId); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(outboundPickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java new file mode 100644 index 000000000..747901a7b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** Immutable SKU stock deltas written in the outbound pick transaction. */ +@Data +@TableName("ll_return_inventory_outbound_movement") +public class ReturnInventoryOutboundMovement { + @TableId(type = IdType.INPUT) private Long id; + private Long outboundPickId; + private String customerId; + private String overseasWarehouseId; + private Long cartonId; + private String cartonMark; + private String outboundNo; + private String skuCode; + private String productNameZh; + private Long quantity; + private Long totalBefore; + private Long totalAfter; + private Long availableBefore; + private Long availableAfter; + private Long cartonsBefore; + private Long cartonsAfter; + private LocalDateTime occurredAt; +} 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 9aaeeb487..caf327ce9 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 @@ -12,6 +12,18 @@ import java.util.List; @Mapper public interface ReturnInventoryBalanceMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE") + ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " + + "available_quantity=available_quantity-#{quantity},carton_quantity=carton_quantity-1,updated_at=NOW(3) " + + "WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " + + "AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1") + int deductOutbound(@Param("id") long id, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("quantity") long quantity); + @Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,total_quantity,available_quantity,carton_quantity,created_at,updated_at) " + "VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{totalQuantity},#{availableQuantity},#{cartons},NOW(3),NOW(3)) " + "ON DUPLICATE KEY UPDATE total_quantity=total_quantity+VALUES(total_quantity),available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java index d4aa8999b..16d780a55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java @@ -7,15 +7,17 @@ import org.apache.ibatis.annotations.Select; import java.util.List; -/** Read-only return inventory movement ledger reconstructed from signed receipts and merged cartons. */ +/** Receipt/merge projections plus immutable outbound SKU movements. */ @Mapper -public interface ReturnInventoryMovementMapper { +public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.core.mapper.BaseMapper { String MOVEMENTS = "SELECT o.customer_id customerId,o.overseas_warehouse_id overseasWarehouseId,ri.sku_code skuCode,oi.product_name_zh productNameZh," + "'RECEIPT' movementType,r.receipt_no referenceNo,ri.received_quantity totalDelta,ri.received_quantity availableDelta,r.signed_at occurredAt " + "FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id JOIN ll_return_receipt_item ri ON ri.receipt_id=r.id " + "JOIN ll_return_order_item oi ON oi.id=ri.return_order_item_id " + "UNION ALL SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,i.product_name_zh,'MERGE',c.carton_mark,0,-i.quantity,c.created_at " + - "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'"; + "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" + + " UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," + + "-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement"; @Select("") + List findByDispatchNos(@Param("numbers") List numbers); + + // Locking read sees a concurrent binding committed while the caller waited for the dispatch row. + @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=#{dispatchId} FOR UPDATE") + FleetTripDispatch lockByDispatch(@Param("dispatchId") long dispatchId); + + @Select("SELECT t.status FROM ll_fleet_trip_dispatch b JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE b.dispatch_id=#{dispatchId}") + String tripStatus(@Param("dispatchId") long dispatchId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java new file mode 100644 index 000000000..0dacd3636 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.fleet.trip; + +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.LocalDateTime; + +@Data +@TableName("ll_fleet_trip") +public class FleetTripEntity { + @TableId(type = IdType.INPUT) private Long id; + private String tripNo; + private String idempotencyKey; + private String requestHash; + private String status; + private Long warehouseId; + private String warehouseCode; + private String warehouseName; + private Long vehicleId; + private String vehiclePlate; + private Long driverId; + private String driverName; + private String driverPhone; + private Integer maxPalletSpaces; + private BigDecimal maxLoadKg; + private BigDecimal maxVolumeCbm; + private LocalDateTime plannedDepartureAt; + private LocalDateTime expectedDriverEndAt; + private LocalDateTime departedAt; + private LocalDateTime completedAt; + private String stopsJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String plannedLoadJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String loadJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String loadingReviewJson; + private String departureKey; + private String departureHash; + private String departureResultJson; + private String completionJson; + private String vehicleReleaseJson; + private String driverReleaseJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String remark; + private Long recordVersion; + private String createdBy; + private LocalDateTime createdAt; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java new file mode 100644 index 000000000..a6ae434a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -0,0 +1,318 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import com.baiyue.lastleg.fleet.driver.DriverModels; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; + +@Service +public class FleetTripExecutionService { + private final FleetTripMapper trips; + private final FleetTripDispatchMapper bindings; + private final FleetResourceMapper resources; + private final FleetDispatchGateway dispatches; + private final FleetTripService history; + private final DriverService drivers; + private final VehicleRepository vehicles; + private final ObjectMapper json; + private final FleetBookingReservations bookingReservations; + + public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources, + FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers, + VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) { + this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches; + this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetExecutionModels.Planning capacityPlan(String tripNo) { + var trip = requireTrip(tripNo, false); + var cargo = cargo(manifests(trip, false)); + return planning(trip, cargo); + } + + @Transactional + public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) { + var trip = requireTrip(tripNo, true); + requireVersionAndPlan(trip, request.recordVersion()); + var cargo = cargo(manifests(trip, true)); + String reason = planningBlockReason(trip, cargo); + if (reason != null) throw conflict(reason); + String fingerprint = hash(write(cargo)); + if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量"); + var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); + var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now()); + bookingReservations.segments(trip, plan, null, null); + trip.setPlannedLoadJson(write(plan)); + history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator); + return planning(trip, cargo); + } + + private FleetExecutionModels.Planning planning(FleetTripEntity trip, List cargo) { + String fingerprint = hash(write(cargo)); + var plan = trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class); + boolean valid = plan != null && plan.manifestHash().equals(fingerprint); + return new FleetExecutionModels.Planning(trip.getTripNo(), trip.getRecordVersion(), cargo, fingerprint, plan, valid, planningBlockReason(trip, cargo), valid ? bookingReservations.segments(trip, plan, null, null) : List.of()); + } + + private String planningBlockReason(FleetTripEntity trip, List cargo) { + if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量"; + if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积"; + if (cargo.isEmpty()) return "请先关联本车自有货排车单"; + if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null)) + return "请先明确每票货物的实物托盘和卸货目的地"; + return null; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetExecutionModels.Loading loading(String tripNo) { + var trip = requireTrip(tripNo, false); + var manifest = manifests(trip, false); + var cargo = cargo(manifest); + return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest)); + } + + @Transactional + public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) { + var trip = requireTrip(tripNo, true); + requireVersionAndPlan(trip, request.recordVersion()); + var manifest = manifests(trip, true); + String block = blockReason(trip, manifest); + if (block != null) throw conflict(block); + var cargo = cargo(manifest); String fingerprint = hash(write(cargo)); + if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载"); + var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); + var segments = capacity(trip, cargo, pallets); + var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now()); + trip.setLoadJson(write(confirmed)); + history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator); + return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) return missingGate(); + return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public Map gates(String warehouseId, List numbers) { + if (numbers.isEmpty()) return Map.of(); + var result = new HashMap(); + numbers.forEach(number -> result.put(number, missingGate())); + var byTrip = new HashMap(); + for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate( + byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding)); + return result; + } + + private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) { + var trip = requireTrip(tripNo, false); + requireWarehouse(trip, warehouseId); + var manifest = manifests(trip, false); + String reason = blockReason(trip, manifest); + String warning = null; + if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); + if (reason == null) reason = loadingReviewBlockReason(trip); + if (reason == null) { + var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对"; + else reason = driverBlockReason(trip, driver); + if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险"; + } + boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null; + return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning, + canPick, canPick ? null : "当前车次不允许继续拣货"); + } + + private static FleetExecutionModels.Gate missingGate() { + return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核"); + } + private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) { + if (!gate.canPick()) return gate; + var review = FleetTripReview.read(json, binding.getReviewJson()); + String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) + ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"; + return gate.withPicking(reason == null, reason); + } + private String loadingReviewBlockReason(FleetTripEntity trip) { + var review = FleetTripReview.read(json, trip.getLoadingReviewJson()); + return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车"; + } + + @Transactional + public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) { + var trip = requireTrip(tripNo, true); + requireVersionAndPlan(trip, request.recordVersion()); + String reason = FleetTripReview.reason(request); + if ("APPROVED".equals(request.decision())) { + if (trip.getDriverId() == null) throw conflict("请先分配司机"); + var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + String driverBlock = driverBlockReason(trip, driver); + if (driverBlock != null) throw conflict(driverBlock); + var manifest = manifests(trip, true); + String block = blockReason(trip, manifest); + if (block == null) block = loadBlockReason(trip, cargo(manifest)); + if (block != null) throw conflict(block); + } + trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion()))); + history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator); + return history.detail(tripNo); + } + + @Transactional + public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) throw conflict("请先在平台将排车单关联车次"); + var trip = requireTrip(binding.getTripNo(), true); + requireWarehouse(trip, warehouseId); + // The binding may have changed while waiting for the trip lock. Never ship a stale parent. + var members = bindings.listByTrip(trip.getId()); + if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新"); + String requestHash = hash(write(request)); + if (trip.getDepartureKey() != null) { + if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash)) + throw conflict("车次已发车,发车请求标识或内容不一致"); + return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay(); + } + requireVersionAndPlan(trip, request.recordVersion()); + // Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches. + if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); + var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); + if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()) + || !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces()) + || !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm())) + throw conflict("车辆状态或容量已变化,请重新确认车次"); + requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId()); + var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + String driverBlock = driverBlockReason(trip, driver); + if (driverBlock != null) throw conflict(driverBlock); + var manifest = manifests(trip, true); + String reason = blockReason(trip, manifest); + if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); + if (reason == null) reason = loadingReviewBlockReason(trip); + if (reason != null) throw conflict(reason); + Instant now = Instant.now(); + trip.setStatus("DEPARTING"); + if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); + var results = new ArrayList(); + for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator, + trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now)); + trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC)); + var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now, + results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false); + trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result)); + history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator); + return result; + } + + private List manifests(FleetTripEntity trip, boolean lock) { + return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList(); + } + private static List cargo(List manifests) { + return manifests.stream().flatMap(manifest -> manifest.cargo().stream()) + .sorted(Comparator.comparing(FleetExecutionModels.Cargo::dispatchNo).thenComparing(FleetExecutionModels.Cargo::dispatchItemId)).toList(); + } + private String blockReason(FleetTripEntity trip, List manifests) { + if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划"; + if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机"; + if (manifests.isEmpty()) return "车次尚未关联排车单"; + if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联"; + for (var binding : bindings.listByTrip(trip.getId())) { + if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; + } + for (var manifest : manifests) { + if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致"; + if (!Objects.equals(trip.getVehiclePlate(), manifest.vehiclePlate()) || !Objects.equals(trip.getDriverName(), manifest.driverName()) + || !Objects.equals(trip.getDriverPhone(), manifest.driverPhone())) return "排车单 " + manifest.dispatchNo() + " 的车辆司机与车次不一致"; + if (manifest.blockReason() != null) return manifest.dispatchNo() + ":" + manifest.blockReason(); + } + return null; + } + private String loadBlockReason(FleetTripEntity trip, List cargo) { + var load = load(trip); + if (load == null) return "请在平台确认本车实际装载"; + if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认"; + try { capacity(trip, cargo, load.pallets()); return null; } + catch (ResponseStatusException error) { return error.getReason(); } + } + private List capacity(FleetTripEntity trip, List cargo, + List pallets) { + List stops; + try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } + catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); } + var destinations = new HashMap(); var palletNumbers = new HashMap(); + if (cargo.isEmpty()) throw conflict("没有待发运货物"); + for (var item : cargo) { + if (item.warehousePalletId() == null || item.palletNo() == null || item.palletNo().isBlank() || item.destinationKey() == null || item.packages() <= 0) + throw conflict("货物实物托盘、数量或目的地尚未确认"); + String previous = destinations.putIfAbsent(item.warehousePalletId(), item.destinationKey()); + if (previous != null && !previous.equals(item.destinationKey())) throw conflict("同一实物托盘包含不同卸货目的地,请先拆托"); + String oldNumber = palletNumbers.putIfAbsent(item.warehousePalletId(), item.palletNo()); + if (oldNumber != null && !oldNumber.equals(item.palletNo())) throw conflict("实物托盘编号不一致,请核对"); + } + var measured = new HashSet(); var bookings = new ArrayList(); + try { + for (var pallet : pallets) { + if (!measured.add(pallet.warehousePalletId())) throw conflict("同一实物托盘不能重复计量"); + if (!destinations.containsKey(pallet.warehousePalletId())) throw conflict("装载包含不属于本车的实物托盘"); + if (pallet.deliveryIndex() < 1 || pallet.deliveryIndex() >= stops.size() + || !destinations.get(pallet.warehousePalletId()).equals(stops.get(pallet.deliveryIndex()).destinationKey())) + throw conflict("托盘卸货站与货物目的地不一致"); + bookings.add(new TripCapacityPolicy.Booking(pallet.warehousePalletId(), 0, pallet.deliveryIndex(), + new TripCapacityPolicy.Load(pallet.palletSpaces(), pallet.weightKg(), pallet.volumeCbm()))); + } + if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积"); + if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null) + throw conflict("车辆容量未确认"); + return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings); + } catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); } + } + private void requireResource(String kind, long id, long tripId) { + var claim = resources.find(kind, id); + if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化"); + } + private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) { + if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案"; + if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照"; + return null; + } + private FleetTripEntity requireTrip(String tripNo, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private static void requireWarehouse(FleetTripEntity trip, String warehouseId) { + if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次"); + } + private static void requireVersionAndPlan(FleetTripEntity trip, long version) { + if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认"); + if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); + } + private static boolean equalNumber(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; } + private FleetExecutionModels.ConfirmedLoad load(FleetTripEntity trip) { return trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + 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 static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java new file mode 100644 index 000000000..05f0085d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_trip_history") +public class FleetTripHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long tripId; + private Long recordVersion; + private String action; + private String reason; + private String operatorId; + private LocalDateTime occurredAt; + private String snapshotJson; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java new file mode 100644 index 000000000..0e6603e89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FleetTripHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java new file mode 100644 index 000000000..430dcc49e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface FleetTripMapper extends BaseMapper { + @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") + FleetTripEntity lockByNo(@Param("tripNo") String tripNo); + @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") + FleetTripEntity findByNo(@Param("tripNo") String tripNo); + @Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key}") + FleetTripEntity findCreated(@Param("operator") String operator, @Param("key") String key); + @Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE") + FleetTripEntity lockCreated(@Param("operator") String operator, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java new file mode 100644 index 000000000..0d59736d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetTripModels { + private FleetTripModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, + String status, @Positive Long warehouseId) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Stop(@NotBlank @Size(max = 128) String name, @Size(max = 500) String address, + @Size(max = 160) String destinationKey) { } + public record Create(@NotNull @Positive Long warehouseId, @NotNull Instant plannedDepartureAt, + @NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops, + @Size(max = 1000) String remark, @NotBlank @Size(min = 8, max = 128) String idempotencyKey) { } + public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, + @NotNull @Positive Long driverId, @NotBlank @Size(max = 1000) String reason) { } + public record Plan(@NotNull @Min(0) Long recordVersion, @NotNull Instant plannedDepartureAt, + @NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops, + @Size(max = 1000) String remark, @NotBlank @Size(max = 1000) String reason) { } + public record Cancel(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason) { } + public record BindingCommand(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String dispatchNo, + @NotBlank @Size(max = 1000) String reason) { } + public record Review(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision, + @Size(max = 1000) String reason) { } + public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) { + public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); } + } + public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review) { } + public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName, + String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm, + Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, + List stops, String remark, long recordVersion, Instant updatedAt, + FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { } + public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List dispatches, ReviewState loadingReview) { } + public record AuditSnapshot(Response trip, List dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad, + FleetCompletionModels.Completed completion, ReviewState loadingReview, + FleetExecutionModels.PlannedLoad capacityPlan, List externalBookings) { } + public record History(long recordVersion, String action, String reason, String operatorId, Instant occurredAt, AuditSnapshot snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java new file mode 100644 index 000000000..03179d7c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +final class FleetTripReview { + private FleetTripReview() { } + static FleetTripModels.ReviewState read(ObjectMapper json, String value) { + if (value == null) return FleetTripModels.ReviewState.pending(); + try { return json.readValue(value, FleetTripModels.ReviewState.class); } + catch (JsonProcessingException error) { throw new IllegalStateException("审核记录读取失败", error); } + } + static String reason(FleetTripModels.Review request) { + if (!"APPROVED".equals(request.decision()) && !"REJECTED".equals(request.decision())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择审核通过或打回"); + String reason = request.reason() == null ? "" : request.reason().trim(); + if ("REJECTED".equals(request.decision()) && reason.isEmpty()) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请填写打回原因"); + return reason.isEmpty() ? "审核通过" : reason; + } + static boolean approved(ObjectMapper json, String value) { return "APPROVED".equals(read(json, value).status()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java new file mode 100644 index 000000000..736accc84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -0,0 +1,336 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.*; +import java.util.*; + +@Service +public class FleetTripService { + private final FleetTripMapper trips; + private final FleetResourceMapper resources; + private final FleetTripHistoryMapper histories; + private final FleetTripDispatchMapper bindings; + private final FleetDispatchGateway dispatches; + private final VehicleRepository vehicles; + private final DriverService drivers; + private final FleetWarehouseDirectory warehouses; + private final ObjectMapper json; + private final Clock clock; + private final FleetBookingReservations bookingReservations; + + @Autowired + public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories, + VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) { + this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC()); + } + FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories, + VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) { + this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles; + this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock; + this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations; + } + + public PageResult page(FleetTripModels.Query query) { + if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status())) + throw bad("车次状态无效"); + var filter = new LambdaQueryWrapper() + .eq(query.status() != null && !query.status().isBlank(), FleetTripEntity::getStatus, query.status()) + .eq(query.warehouseId() != null, FleetTripEntity::getWarehouseId, query.warehouseId()); + if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim()) + .or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim())); + filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId); + var page = trips.selectPage(new Page<>(query.page(), query.size()), filter); + return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetTripModels.Detail detail(String tripNo) { + FleetTripEntity trip = required(tripNo); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) { + String hash = hash(write(request)); + var existing = trips.findCreated(operator, request.idempotencyKey()); + if (existing != null) return replay(existing, hash); + validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); + var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); + var trip = new FleetTripEntity(); + trip.setId(IdWorker.getId()); + trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash); + trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id())); + trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); + trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); + trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); + trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator); + trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt()); + try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); } + catch (DuplicateKeyException duplicate) { + var concurrent = trips.lockCreated(operator, request.idempotencyKey()); + if (concurrent == null) throw conflict("车次编号冲突,请重试"); + return replay(concurrent, hash); + } + record(trip, "CREATE", null, operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) { + FleetTripEntity trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + // All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard. + var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆"); + if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null + || vehicle.maxPalletSpaces() <= 0 || vehicle.maxLoadKg().signum() <= 0 || vehicle.maxVolumeCbm().signum() <= 0) + throw bad("请先确认车辆的托位、载重和容积"); + if (trip.getLoadJson() != null) { + var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); + if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces() + || segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0 + || segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0)) + throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排"); + } + if (!Objects.equals(trip.getVehicleId(), vehicle.id())) + bookingReservations.requireReassignmentFits(trip, vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm()); + var driver = drivers.requireDispatchable(request.driverId(), instant(trip.getExpectedDriverEndAt())); + if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机"); + reserve("VEHICLE", request.vehicleId(), trip); + reserve("DRIVER", request.driverId(), trip); + if (trip.getVehicleId() != null && !trip.getVehicleId().equals(request.vehicleId())) release("VEHICLE", trip.getVehicleId(), trip.getId()); + if (trip.getDriverId() != null && !trip.getDriverId().equals(request.driverId())) release("DRIVER", trip.getDriverId(), trip.getId()); + trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber()); + trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone()); + for (var binding : bindings.listByTrip(trip.getId())) { + var dispatch = dispatches.lockForAssignment(binding.getDispatchNo()); + if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致"); + dispatches.assign(dispatch, assignment(trip)); + } + persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator); + return new FleetTripModels.Detail(response(trip), driver.qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson())); + } + + @Transactional + public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) { + FleetTripEntity trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); + bookingReservations.requireRoute(trip, request.stops()); + if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt()); + boolean unchangedRoute = response(trip).stops().equals(request.stops()); + boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) + && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); + for (var binding : bindings.listByTrip(trip.getId())) { + if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo()); + else dispatches.lockForPlanning(binding.getDispatchNo()); + } + trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); + trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); + persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) { + var trip = planned(tripNo, request.recordVersion()); + String reason = FleetTripReview.reason(request); + if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); + if ("APPROVED".equals(request.decision())) { + var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车"); + var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())); + if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机"); + } + var dispatch = dispatches.lockForAssignment(dispatchNo); + var binding = bindings.lockByDispatch(dispatch.dispatchId()); + if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); + binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion()))); + if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败"); + trip.setLoadingReviewJson(null); + persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) { + FleetTripEntity trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次"); + if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次"); + if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId()); + if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId()); + trip.setStatus("CANCELLED"); + persist(trip, request.recordVersion(), "CANCEL", request.reason(), operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { + var trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); + var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); + if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); + var bound = bindings.lockByDispatch(dispatch.dispatchId()); + if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo()); + var binding = new FleetTripDispatch(); + binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId()); + binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount()); + binding.setBoundBy(operator); binding.setBoundAt(now()); + try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); } + catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } + bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); + dispatches.assign(dispatch, assignment(trip)); + persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { + var trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); + var binding = bindings.lockByDispatch(dispatch.dispatchId()); + if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); + dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null)); + if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试"); + bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator); + persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator); + return detail(trip); + } + + public PageResult history(String tripNo, int page, int size) { + var trip = required(tripNo); + var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + .eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion)); + return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(), + event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(), + result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); + } + + private void reserve(String kind, long resourceId, FleetTripEntity trip) { + var current = resources.find(kind, resourceId); + if (current != null) { + if (current.tripId() == trip.getId()) return; + throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用"); + } + try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); } + catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); } + } + + private void release(String kind, long resourceId, long tripId) { + if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成"); + } + + private FleetTripEntity required(String number) { + var trip = trips.findByNo(number); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private FleetTripEntity planned(String number, long version) { + var trip = trips.lockByNo(number); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试"); + if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); + return trip; + } + private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) { + if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容"); + return detail(trip); + } + private FleetTripModels.Detail detail(FleetTripEntity trip) { + return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null + : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson())); + } + private List bindingResponses(FleetTripEntity trip) { + return bindings.listByTrip(trip.getId()).stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(), + binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()))).toList(); + } + private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) { + return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone()); + } + private FleetTripModels.Response response(FleetTripEntity trip) { + List stops; + try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } + catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); } + return new FleetTripModels.Response(trip.getTripNo(), trip.getStatus(), trip.getWarehouseId().toString(), trip.getWarehouseCode(), trip.getWarehouseName(), + text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(), + trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), + instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()), + releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson())); + } + private void validatePlan(Instant departure, Instant end, List stops) { + if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间"); + try { + if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围"); + } catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); } + if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null)) + throw bad("车次须包含起点和目的地,最多200个站点"); + } + private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) { + if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null); + if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null); + if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null); + if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper() + .eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null)); + trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator); + if (trips.update(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) + throw conflict("车次已被修改,请刷新后重试"); + record(trip, action, reason, operator); + } + private void record(FleetTripEntity trip, String action, String reason, String operator) { + var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion()); + event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); + event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null + : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null + : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()), + trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId())))); + if (histories.insert(event) != 1) throw conflict("车次历史保存失败"); + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } } + private FleetCompletionModels.ReleaseInfo releaseInfo(String value) { + if (value == null) return null; + var released = read(value, FleetCompletionModels.Released.class); + return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus()); + } + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) { + persist(trip, version, action, reason, operator); + } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } + 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 LocalDateTime now() { return utc(clock.instant()); } + private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); } + private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); } + private static String text(Long value) { return value == null ? null : value.toString(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java new file mode 100644 index 000000000..cb015a615 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; +import org.springframework.stereotype.Component; + +@Component +public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard { + private final FleetResourceMapper resources; + public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; } + @Override public void requireUnassigned(long vehicleId) { + var assignment = resources.find("VEHICLE", vehicleId); + if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT, + "车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java new file mode 100644 index 000000000..239d31065 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController @RequestMapping("/api/platform/v1/last-leg/fleet/trips") +public class PlatformFleetBookingController { + private final FleetBookingService service; + private final FleetCustomerDirectory customers; + public PlatformFleetBookingController(FleetBookingService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;} + @GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));} + @GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));} + @PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));} + @PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Update request){return ApiResponse.success(service.update(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')") + public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.confirm(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.cancel(tripNo,bookingNo,operator(user),request));} + @GetMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse incoming(@PathVariable String tripNo,@PathVariable String bookingNo){return ApiResponse.success(service.incoming(tripNo,bookingNo));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')") + public ApiResponse incoming(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.startIncoming(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')") + public ApiResponse receipt(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/dispatch") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.BindDispatch request){return ApiResponse.success(service.bindDispatch(tripNo,bookingNo,operator(user),request));} + private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");return user.userId().toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java new file mode 100644 index 000000000..18259b501 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -0,0 +1,120 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/trips") +public class PlatformFleetTripController { + private final FleetTripService service; + private final FleetTripExecutionService execution; + private final FleetTripCompletionService completion; + public PlatformFleetTripController(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) { + this.service = service; this.execution = execution; this.completion = completion; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> page(@Valid FleetTripModels.Query query) { return ApiResponse.success(service.page(query)); } + @GetMapping("/{tripNo}") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) { + return ApiResponse.success(service.create(operator(user), request)); + } + @PutMapping("/{tripNo}/plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) { + return ApiResponse.success(service.updatePlan(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/assignment") + @PreAuthorize("hasAuthority('platform:fleet:trip:assign')") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) { + return ApiResponse.success(service.assign(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/cancel") + @PreAuthorize("hasAuthority('platform:fleet:trip:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { + return ApiResponse.success(service.cancel(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/dispatches/bind") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetTripModels.BindingCommand request) { + return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/dispatches/unbind") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetTripModels.BindingCommand request) { + return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request)); + } + @GetMapping("/{tripNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> history(@PathVariable String tripNo, + @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) { + return ApiResponse.success(service.history(tripNo, page, size)); + } + @PostMapping("/{tripNo}/dispatches/{dispatchNo}/review") + @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") + public ApiResponse reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) { + return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request)); + } + @PostMapping("/{tripNo}/loading-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") + public ApiResponse reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetTripModels.Review request) { + return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request)); + } + @GetMapping("/{tripNo}/loading") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); } + @GetMapping("/{tripNo}/capacity-plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse capacityPlan(@PathVariable String tripNo) { return ApiResponse.success(execution.capacityPlan(tripNo)); } + @PostMapping("/{tripNo}/capacity-plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse confirmCapacityPlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetExecutionModels.Confirm request) { + return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/loading") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetExecutionModels.Confirm request) { + return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request)); + } + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } + @GetMapping("/{tripNo}/completion") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse completion(@PathVariable String tripNo) { return ApiResponse.success(completion.inspection(tripNo)); } + @PostMapping("/{tripNo}/complete") + @PreAuthorize("hasAuthority('platform:fleet:trip:complete')") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetCompletionModels.Complete request) { + return ApiResponse.success(completion.complete(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/resources/release") + @PreAuthorize("hasAuthority('platform:fleet:trip:release')") + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetCompletionModels.Release request) { + return ApiResponse.success(completion.release(tripNo, operator(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java index cb5252cf7..c889adaf6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -48,6 +48,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository { record.setId(IdWorker.getId()); record.setPlateNumber(vehicle.plateNumber()); record.setModel(vehicle.model()); + record.setMaxPalletSpaces(vehicle.maxPalletSpaces()); + record.setMaxLoadKg(vehicle.maxLoadKg()); + record.setMaxVolumeCbm(vehicle.maxVolumeCbm()); record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); record.setMileageKm(vehicle.mileageKm()); record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); @@ -76,6 +79,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository { .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) .set(VehicleRecord::getPlateNumber, changes.plateNumber()) .set(VehicleRecord::getModel, changes.model()) + .set(VehicleRecord::getMaxPalletSpaces, changes.maxPalletSpaces()) + .set(VehicleRecord::getMaxLoadKg, changes.maxLoadKg()) + .set(VehicleRecord::getMaxVolumeCbm, changes.maxVolumeCbm()) .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) .set(VehicleRecord::getMileageKm, changes.mileageKm()) .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) @@ -112,7 +118,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository { return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), - value.getUpdatedBy(), value.getUpdatedAt()); + value.getUpdatedBy(), value.getUpdatedAt(), value.getMaxPalletSpaces(), value.getMaxLoadKg(), value.getMaxVolumeCbm()); } private static long version(Long value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java index 9518e94e4..9ae5a923b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -14,6 +14,9 @@ public class VehicleRecord { @TableId(type = IdType.INPUT) private Long id; private String plateNumber; private String model; + private Integer maxPalletSpaces; + private BigDecimal maxLoadKg; + private BigDecimal maxVolumeCbm; private BigDecimal fuelConsumptionPer100Km; private Long mileageKm; private String driverOrDispatchCompany; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java index 79fbb24d1..d350c34e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.masterdata.vehicle.application; import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; @@ -24,9 +25,11 @@ public class VehicleApplicationService { private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); private final VehicleRepository vehicles; + private final VehicleAssignmentGuard assignments; - public VehicleApplicationService(VehicleRepository vehicles) { + public VehicleApplicationService(VehicleRepository vehicles, VehicleAssignmentGuard assignments) { this.vehicles = vehicles; + this.assignments = assignments; } public PageResult page(VehicleModels.Query query) { @@ -41,6 +44,7 @@ public class VehicleApplicationService { @Transactional public VehicleModels.Response create(String operator, VehicleModels.Create request) { + validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()); LocalDateTime now = LocalDateTime.now(); long id; try { @@ -48,7 +52,8 @@ public class VehicleApplicationService { requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), - ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now)); + ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now, + request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm())); } catch (DuplicatePlateException exception) { throw conflict("车牌号已存在"); } @@ -57,16 +62,25 @@ public class VehicleApplicationService { @Transactional public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()); VehicleSnapshot current = locked(id); requireVersion(current, request.recordVersion()); if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + if (!current.plateNumber().equals(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)) + || !current.model().equals(requiredText(request.model(), "车型")) || !current.region().equals(region(request.region())) + || !current.ownership().equals(ownership(request.ownership())) + || !java.util.Objects.equals(current.maxPalletSpaces(), request.maxPalletSpaces()) + || !sameDecimal(current.maxLoadKg(), request.maxLoadKg()) || !sameDecimal(current.maxVolumeCbm(), request.maxVolumeCbm())) { + assignments.requireUnassigned(id); + } boolean updated; try { updated = vehicles.update(id, request.recordVersion(), new VehicleChanges( requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), - ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now())); + ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now(), + request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm())); } catch (DuplicatePlateException exception) { throw conflict("车牌号已存在"); } @@ -80,6 +94,7 @@ public class VehicleApplicationService { requireVersion(current, request.recordVersion()); String status = status(request.status()); if (status.equals(current.status())) return response(current); + assignments.requireUnassigned(id); boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); return detail(id); @@ -87,11 +102,12 @@ public class VehicleApplicationService { public byte[] exportCsv(VehicleModels.Query query) { var rows = vehicles.list(criteria(query)).stream().map(this::response).toList(); - StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); - for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') + StringBuilder csv = new StringBuilder("\uFEFF车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n"); + for (var row : rows) csv.append(cell(row.plateNumber())).append(',') .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') + .append(cell(row.maxPalletSpaces())).append(',').append(cell(row.maxLoadKg())).append(',').append(cell(row.maxVolumeCbm())).append(',') .append(row.updatedAt()).append("\r\n"); return csv.toString().getBytes(StandardCharsets.UTF_8); } @@ -115,7 +131,17 @@ public class VehicleApplicationService { return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(), value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(), value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(), - value.updatedBy(), value.updatedAt()); + value.updatedBy(), value.updatedAt(), value.maxPalletSpaces(), value.maxLoadKg(), value.maxVolumeCbm()); + } + + private static void validateCapacity(Integer pallets, java.math.BigDecimal weight, java.math.BigDecimal volume) { + if (pallets == null && weight == null && volume == null) return; + if (pallets == null || weight == null || volume == null || pallets < 1 || pallets > 10000 + || weight.signum() <= 0 || volume.signum() <= 0) throw badRequest("车辆托位、载重和容积须同时填写且大于零,或全部留空待确认"); + } + + private static boolean sameDecimal(java.math.BigDecimal first, java.math.BigDecimal second) { + return first == null ? second == null : second != null && first.compareTo(second) == 0; } private void requireVersion(VehicleSnapshot value, long expected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java index 36a307164..c00dcb711 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -26,7 +26,10 @@ public final class VehicleModels { @Size(max = 128) String driverOrDispatchCompany, @Size(max = 64) String phone, @NotBlank String region, @NotBlank String ownership, - @NotBlank String status, @Size(max = 1000) String remark) { } + @NotBlank String status, @Size(max = 1000) String remark, + @Min(1) @Max(10000) Integer maxPalletSpaces, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { } public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String plateNumber, @@ -36,12 +39,16 @@ public final class VehicleModels { @Size(max = 128) String driverOrDispatchCompany, @Size(max = 64) String phone, @NotBlank String region, @NotBlank String ownership, - @Size(max = 1000) String remark) { } + @Size(max = 1000) String remark, + @Min(1) @Max(10000) Integer maxPalletSpaces, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { } public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { } public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, String remark, long recordVersion, - String updatedBy, LocalDateTime updatedAt) { } + String updatedBy, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java new file mode 100644 index 000000000..950631ab0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +/** Called while the vehicle row is locked; fleet owns the active assignment. */ +public interface VehicleAssignmentGuard { + void requireUnassigned(long vehicleId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java index 31f0b89a9..0f4f9147b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -26,16 +26,19 @@ public interface VehicleRepository { record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, - String remark, String operator, LocalDateTime createdAt) { } + String remark, String operator, LocalDateTime createdAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String remark, - String operator, LocalDateTime updatedAt) { } + String operator, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, String remark, long recordVersion, - String updatedBy, LocalDateTime updatedAt) { } + String updatedBy, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } final class DuplicatePlateException extends RuntimeException { public DuplicatePlateException(Throwable cause) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 609d90308..7572a8948 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -94,6 +94,15 @@ public final class OverseasPermissions { public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit"; public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status"; public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export"; + public static final String VEHICLE_RECORD_CREATE = "platform:overseas-master-data:vehicle:record-create"; + public static final String VEHICLE_RECORD_EDIT = "platform:overseas-master-data:vehicle:record-edit"; + public static final String VEHICLE_RECORD_UPLOAD = "platform:overseas-master-data:vehicle:record-upload"; + public static final String DRIVER_LIST = "platform:overseas-master-data:driver:list"; + public static final String DRIVER_CREATE = "platform:overseas-master-data:driver:create"; + public static final String DRIVER_EDIT = "platform:overseas-master-data:driver:edit"; + public static final String DRIVER_STATUS = "platform:overseas-master-data:driver:status"; + public static final String DRIVER_REVIEW = "platform:overseas-master-data:driver:review"; + public static final String DRIVER_UPLOAD = "platform:overseas-master-data:driver:upload"; private OverseasPermissions() { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java new file mode 100644 index 000000000..bca2ebf79 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.transfer.fleet; + +import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +@Component +public class FleetIncomingAdapter implements FleetBookingIncomingGateway { + private final WarehouseTransferService service; + private final WarehouseTransferOrderMapper orders; + private final ObjectMapper json; + public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;} + @Override @Transactional(propagation=Propagation.MANDATORY) + public Evidence create(String operator,Incoming input){ + boolean fba=input.cargo().destinationKey().startsWith("FBA:"); + String code=fba?input.cargo().destinationKey().substring(4):null; + String key=FleetDispatchGateway.destinationKey(fba?"FBA":"PRIVATE",code,code,input.privateAddress(),input.postalCode()); + if(!Objects.equals(key,input.cargo().destinationKey()))throw conflict("收货目的地与已确认外接卸货站不一致"); + var request=new WarehouseTransferRequests.Create(null,List.of(),input.warehouseId(),code, + fba?"FBA":"PRIVATE",null,code,code,input.privateAddress(),input.postalCode(),input.recipientName(),input.recipientPhone(), + input.deliveryInstructions()==null||input.deliveryInstructions().isBlank()?"客户自送至"+input.warehouseName():input.deliveryInstructions(), + input.cargo().contactName(),input.cargo().contactPhone(),input.cargo().packageCount(),input.cargo().volumeCbm(),"CUSTOMER_DELIVERY",input.estimatedArrivalAt(),null, + null,null,null,"外接预订 "+input.bookingNo(),false,null,null,null,null,null,"FLEET:"+input.bookingNo()+":INCOMING", + List.of(new WarehouseTransferRequests.Item(null,input.cargo().cargoName(),input.cargo().packageCount(),null)),null); + var created=service.createAndSubmit(input.customerId(),operator,request); + var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货")); + return evidence(approved); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public Evidence inspect(String customerId,String incomingNo,boolean lock){ + var row=lock?orders.lockNo(customerId,incomingNo):orders.findNo(customerId,incomingNo); + if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前客户无此外接来货单"); + return evidence(service.detail(customerId,incomingNo)); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public Evidence confirm(String customerId,String incomingNo,String operator,String key){ + var row=orders.lockNo(customerId,incomingNo); + if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"外接来货单不存在"); + if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(service.detail(customerId,incomingNo)); + return evidence(service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量"))); + } + private Evidence evidence(WarehouseTransferResponses.Detail value){ + var pallets=value.pallets().stream().filter(row->"COMPLETED".equals(row.status())).map(row->new Pallet( + row.warehousePalletId()==null?null:row.warehousePalletId().toString(),row.palletNo(),row.cartonCount(),row.grossWeightKg(),row.volumeCbm())) + .sorted(Comparator.comparing(row->Objects.requireNonNullElse(row.warehousePalletId(),""))).toList(); + String destination=FleetDispatchGateway.destinationKey(value.destinationType(),value.destinationWarehouseCode(),value.fbaWarehouseCode(),value.privateDestinationAddress(),value.privateDestinationPostalCode()); + int packages=pallets.stream().mapToInt(Pallet::packages).sum(); + try{ + String hash=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(Arrays.asList(value.transferNo(),value.inboundCode(),value.operatingWarehouseId(),destination,pallets)).getBytes(StandardCharsets.UTF_8))); + return new Evidence(value.transferNo(),value.inboundCode(),value.operatingWarehouseId(),destination,value.status(),hash,pallets,packages); + }catch(Exception error){throw new IllegalStateException(error);} + } + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} +} 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 b4174ee1b..4035b113a 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 @@ -953,7 +953,7 @@ public class WarehouseTransferService { String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transfer.getTransferNo() + ":" + pallet.getSequenceNo() + ":" + item.getId(); InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, - new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + new InventoryCommandRequests.StockIn(customerId, "—", transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), transfer.getInboundOrderId(), transfer.getTransferNo(), transfer.getTransferNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index b3dafc099..705de1fd8 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -299,7 +299,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( seller_name VARCHAR(128) NULL COMMENT '卖家姓名', package_count INT NOT NULL DEFAULT 0 COMMENT '派送箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '派送托盘数', - total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空', total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', @@ -968,7 +968,65 @@ CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) ) ENGINE=InnoDB COMMENT='月度汇率变更历史'; +CREATE TABLE ll_fleet_driver ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号', + warehouse_id BIGINT NULL COMMENT '所属公共仓库引用', + warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照', + warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照', + vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述', + languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域', + medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质', + training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质', + insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质', + name VARCHAR(128) NOT NULL COMMENT '姓名', + phone VARCHAR(64) NOT NULL COMMENT '联系电话', + ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派', + region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回', + license_number VARCHAR(128) NULL COMMENT '驾驶证编号', + license_type VARCHAR(64) NULL COMMENT '驾驶证类型', + license_issued_on DATE NULL COMMENT '驾驶证签发日期', + license_expires_on DATE NULL COMMENT '驾驶证有效截止日', + license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区', + license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_no (driver_no), + KEY idx_fleet_driver_page (active,review_status,updated_at,id), + KEY idx_fleet_driver_expiry (license_expires_on), + CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')), + CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案'; + +CREATE TABLE ll_fleet_driver_history ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_id BIGINT NOT NULL COMMENT '司机内部引用', + record_version BIGINT NOT NULL COMMENT '操作后的档案版本', + action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作', + reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见', + license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用', + snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version), + KEY idx_fleet_driver_history_file (driver_id,license_file_id), + CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史'; + CREATE TABLE IF NOT EXISTS ll_vehicle ( + max_pallet_spaces INT NULL COMMENT '最大标准托位,未知时为空', + max_load_kg DECIMAL(12,3) NULL COMMENT '最大载重,千克', + max_volume_cbm DECIMAL(12,3) NULL COMMENT '可用容积,立方米', id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', @@ -979,9 +1037,181 @@ CREATE TABLE IF NOT EXISTS ll_vehicle ( created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), - KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) + KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id), + CONSTRAINT chk_ll_vehicle_capacity CHECK ((max_pallet_spaces IS NULL AND max_load_kg IS NULL AND max_volume_cbm IS NULL) + OR (max_pallet_spaces IS NOT NULL AND max_load_kg IS NOT NULL AND max_volume_cbm IS NOT NULL + AND max_pallet_spaces BETWEEN 1 AND 10000 AND max_load_kg > 0 AND max_volume_cbm > 0)) ) ENGINE=InnoDB COMMENT='平台车辆主数据'; +CREATE TABLE ll_fleet_vehicle_record ( + id BIGINT NOT NULL COMMENT '记录内部主键', + record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号', + vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键', + vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照', + record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常', + occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)', + details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照', + files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人', + updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_no (record_no), + UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key), + KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id), + CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录'; + +CREATE TABLE ll_fleet_vehicle_record_history ( + id BIGINT NOT NULL COMMENT '历史内部主键', + record_id BIGINT NOT NULL COMMENT '运维记录主键', + record_version BIGINT NOT NULL COMMENT '对应业务版本', + action VARCHAR(32) NOT NULL COMMENT '操作类型', + reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)', + snapshot_json JSON NOT NULL COMMENT '当时完整业务快照', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_revision (record_id, record_version), + CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; + +CREATE TABLE ll_fleet_trip ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256', + status VARCHAR(16) NOT NULL COMMENT '车次状态', + warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用', + warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照', + warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照', + vehicle_id BIGINT NULL COMMENT '车辆档案引用', + vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照', + driver_id BIGINT NULL COMMENT '司机档案引用', + driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话', + max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', + max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', + max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', + planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', + expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + departed_at DATETIME(3) NULL COMMENT '实际发车UTC', + completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', + stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', + planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量', + load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照', + loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核', + departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识', + departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要', + departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果', + completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照', + vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录', + driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_no (trip_no), + UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key), + KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id), + CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id), + CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')), + CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次'; + +CREATE TABLE ll_fleet_external_booking ( + id BIGINT NOT NULL COMMENT '内部主键', + booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + customer_id VARCHAR(128) NOT NULL COMMENT '委托客户及付款归属', + customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照', + customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', + status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', + cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键', + destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称', + agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收', + idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键', + request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要', + confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键', + confirm_hash CHAR(64) NULL COMMENT '承运确认摘要', + cancel_key VARCHAR(128) NULL COMMENT '取消请求键', + cancel_hash CHAR(64) NULL COMMENT '取消摘要', + incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号', + dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号', + receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据', + incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键', + incoming_hash CHAR(64) NULL COMMENT '来货登记摘要', + receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键', + receipt_hash CHAR(64) NULL COMMENT '实收确认摘要', + binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', + binding_hash CHAR(64) NULL COMMENT '关联发运摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_booking_no(booking_no), + UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), + KEY idx_fleet_booking_trip(trip_id,status,id), + UNIQUE KEY uk_fleet_booking_incoming(incoming_no), + KEY idx_fleet_booking_dispatch(dispatch_no), + CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; + +CREATE TABLE ll_fleet_resource_assignment ( + resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', + resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用', + trip_id BIGINT NOT NULL COMMENT '占用车次', + trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号', + PRIMARY KEY (resource_kind,resource_id), + KEY idx_fleet_resource_trip (trip_id), + CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; + +CREATE TABLE ll_fleet_trip_dispatch ( + dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', + customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', + trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + package_count INT NOT NULL COMMENT '关联时待发运箱数快照', + bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', + bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', + review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', + PRIMARY KEY (dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), + KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; + +CREATE TABLE ll_fleet_trip_history ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + record_version BIGINT NOT NULL COMMENT '操作后版本', + action VARCHAR(32) NOT NULL COMMENT '车次操作', + reason VARCHAR(1000) NULL COMMENT '操作原因', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version), + CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); @@ -1389,7 +1619,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', - gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', @@ -1510,13 +1740,14 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', - gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', - request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + request_hash CHAR(64) NULL COMMENT '创建或转派送业务请求摘要,用于幂等内容校验', + fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) @@ -1526,7 +1757,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index ad82f7431..fa84f738a 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -5,7 +5,7 @@ info: description: | 平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 - RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 + RECEIPT 签收文件只有在关联出库单已OUTBOUNDED、PUT保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。发车前可保存BOL及装车资料,不能通过提前保存签收文件绕过实际出库。allowedActions 不再返回人工 COMPLETE_DELIVERY。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] diff --git a/lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml new file mode 100644 index 000000000..fc5cead6e --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml @@ -0,0 +1,310 @@ +openapi: 3.0.3 +info: + title: 平台车辆管理司机档案 + version: 1.0.0 + description: >- + 平台司机主数据,列表权限包含详情、资格预览、历史和已关联证照读取。 + 写操作独立鉴权且要求当前recordVersion;创建版本0,后续每次成功写入递增。 + 证照或身份关键字段变化、替换附件后重新待审;电话和备注更正不撤销既有审核。 + 驾驶证是当前资格最低要求,其他资质不隐式必填。日期按licenseZone计算, + 30天内到期但仍有效允许并提醒,任务结束达到有效截止日次日当地零点或更晚则禁止。 + 缺少预计结束时间时资格返回MISSING_TRIP_END;资格预览不预留司机,不代替发车事务复核。 + 新档案允许缺证照保存为PENDING,通过审核必须补齐编号、类型、有效期和真实附件。 + 所有业务错误遵循HTTP200加code,认证或基础设施错误可返回401/403/503。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/drivers: + get: + summary: 分页查询司机及资格 + description: '权限:platform:overseas-master-data:driver:list;无逐行文件请求。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: active, in: query, schema: {type: boolean}} + - {name: reviewStatus, in: query, schema: {type: string, enum: [PENDING, APPROVED, REJECTED]}} + - {name: ownership, in: query, schema: {type: string, enum: [OWNED, DISPATCHED]}} + - {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}} + - {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示} + - {name: expectedEnd, in: query, schema: {type: string, format: date-time}} + responses: + '200': + description: 业务响应,分页数据 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Driver'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + summary: {$ref: '#/components/schemas/Summary'} + post: + summary: 创建待审司机档案 + description: '权限:platform:overseas-master-data:driver:create' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Draft'}}}} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/warehouse-options: + get: + summary: 获取当前区域可关联的公共仓库 + description: '权限:platform:overseas-master-data:driver:list;复用Warehouse内部有效目录,国内DOMESTIC,海外OVERSEAS/OVERSEAS_PARTNER,排除FBA目的仓;保存时再次校验。' + parameters: + - {name: region, in: query, required: true, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}} + responses: + '200': + description: 业务响应,data为仓库候选 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {type: array, items: {$ref: '#/components/schemas/WarehouseOption'}} + /api/platform/v1/last-leg/fleet/drivers/{id}: + parameters: [{$ref: '#/components/parameters/Id'}] + get: + summary: 司机详情 + description: '权限:platform:overseas-master-data:driver:list' + parameters: + - {name: expectedEnd, in: query, schema: {type: string, format: date-time}} + responses: {'200': {$ref: '#/components/responses/Driver'}} + put: + summary: 编辑档案,关键字段变化后重新待审 + description: '权限:platform:overseas-master-data:driver:edit' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, details] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + details: {$ref: '#/components/schemas/Draft'} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/status: + parameters: [{$ref: '#/components/parameters/Id'}] + patch: + summary: 启停司机并记录原因 + description: '权限:platform:overseas-master-data:driver:status;停用后不可派车,历史保留。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, active, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + active: {type: boolean} + reason: {type: string, minLength: 1, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/review: + parameters: [{$ref: '#/components/parameters/Id'}] + post: + summary: 通过或打回司机证照 + description: '权限:platform:overseas-master-data:driver:review;仅PENDING允许审核;打回原因必填;通过前校验真实附件归属与证照有效性。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, decision] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + decision: {type: string, enum: [APPROVED, REJECTED]} + reason: {type: string, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/submit: + parameters: [{$ref: '#/components/parameters/Id'}] + post: + summary: 已打回档案重新提交 + description: '权限:platform:overseas-master-data:driver:edit;仅REJECTED;先校验证照完整及文件归属。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/qualification: + parameters: [{$ref: '#/components/parameters/Id'}] + get: + summary: 按预计任务结束时间预览派车资格 + description: '权限:platform:overseas-master-data:driver:list;预计结束应覆盖本司机承担的返程或转场。' + parameters: + - {name: expectedEnd, in: query, required: true, schema: {type: string, format: date-time}} + responses: + '200': + description: 资格结果,canDispatch与expiringSoon独立 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/Qualification'} + /api/platform/v1/last-leg/fleet/drivers/{id}/history: + parameters: [{$ref: '#/components/parameters/Id'}] + get: + summary: 分页读取司机操作与审核历史 + description: '权限:platform:overseas-master-data:driver:list;按版本降序,snapshot是历史当时的档案及资格,不能用作当前派车门禁。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 业务响应,data为含records/page/size/total/pages的分页 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/History'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + /api/platform/v1/last-leg/fleet/drivers/{id}/license: + parameters: [{$ref: '#/components/parameters/Id'}] + post: + summary: 上传并绑定驾驶证,重置为待审 + description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;非空PDF/JPG/PNG不超过10MiB,校验MIME和签名,旧版本附件保留在历史中。' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [recordVersion, file] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + file: {type: string, format: binary} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/license/{fileId}: + parameters: + - {$ref: '#/components/parameters/Id'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + get: + summary: 下载当前或历史已关联驾驶证 + description: '权限:platform:overseas-master-data:driver:list;校验关联和File复合归属,attachment下载,Cache-Control no-store,拒绝未绑定文件。' + responses: + '200': + description: 文件内容;业务错误仍需识别JSON响应 + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + Driver: + description: 业务响应,data为司机详情 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/Driver'} + schemas: + Envelope: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: {type: object, nullable: true} + Draft: + type: object + required: [name, phone, ownership, region, licenseZone] + properties: + name: {type: string, minLength: 1, maxLength: 128} + phone: {type: string, minLength: 1, maxLength: 64} + ownership: {type: string, enum: [OWNED, DISPATCHED]} + region: {type: string, enum: [DOMESTIC, OVERSEAS]} + licenseNumber: {type: string, nullable: true, maxLength: 128} + licenseType: {type: string, nullable: true, maxLength: 64} + licenseIssuedOn: {type: string, format: date, nullable: true} + licenseExpiresOn: {type: string, format: date, nullable: true} + licenseZone: {type: string, maxLength: 64, description: IANA时区,如America/Los_Angeles} + remark: {type: string, nullable: true, maxLength: 1000} + profile: {$ref: '#/components/schemas/Profile'} + Profile: + type: object + nullable: true + description: 可选补充资料;当前其他资质日期不自动作为派车必需条件 + properties: + warehouseId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'} + vehicleModels: {type: string, nullable: true, maxLength: 128} + languagesAndArea: {type: string, nullable: true, maxLength: 256} + medicalExpiresOn: {type: string, format: date, nullable: true} + trainingExpiresOn: {type: string, format: date, nullable: true} + insuranceExpiresOn: {type: string, format: date, nullable: true} + WarehouseOption: + type: object + properties: + id: {type: string} + code: {type: string} + name: {type: string} + region: {type: string, enum: [DOMESTIC, OVERSEAS]} + Summary: + type: object + description: 与列表共用筛选范围,按各证照时区计算30天(含)提醒,不以浏览器时区计算 + properties: + total: {type: integer, format: int64} + owned: {type: integer, format: int64} + dispatched: {type: integer, format: int64} + expiringSoon: {type: integer, format: int64} + expired: {type: integer, format: int64} + Driver: + type: object + properties: + id: {type: string} + driverNo: {type: string, description: 独立生成的业务编号} + details: {$ref: '#/components/schemas/Draft'} + active: {type: boolean} + reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]} + licenseFileId: {type: string, nullable: true} + recordVersion: {type: integer, format: int64} + updatedBy: {type: string} + updatedAt: {type: string, description: UTC本地日期时间} + qualification: {$ref: '#/components/schemas/Qualification'} + licenseState: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]} + warehouseCode: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + Qualification: + type: object + properties: + canDispatch: {type: boolean} + expiringSoon: {type: boolean} + reason: + type: string + enum: [ELIGIBLE, INACTIVE, MISSING_LICENSE, MISSING_EXPIRY, PENDING_REVIEW, REJECTED, EXPIRED, MISSING_TRIP_END, INVALID_TRIP_END, EXPIRES_DURING_TRIP] + licenseExpiresOn: {type: string, format: date, nullable: true} + History: + type: object + properties: + recordVersion: {type: integer, format: int64} + action: {type: string, enum: [CREATE, UPDATE, ACTIVATE, DEACTIVATE, UPLOAD_LICENSE, APPROVED, REJECTED, RESUBMIT]} + reason: {type: string, nullable: true} + operatorId: {type: string} + occurredAt: {type: string, description: UTC本地日期时间} + snapshot: {$ref: '#/components/schemas/Driver'} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml new file mode 100644 index 000000000..bb1460d92 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -0,0 +1,649 @@ +openapi: 3.0.3 +info: + title: 平台运输车次与资源分配 + version: 1.0.0 + description: >- + 车次是一次实际运输的稳定业务编号,客户排车、库存和派送单继续保留各自客户归属。 + 本文件定义计划、资源分配及客户排车单关联命令;分配或关联不会扣库存或标记发车。 + 创建可暂不指定车与司机,幂等键按创建人隔离;相同键相同内容返回同一车次,不同内容拒绝。 + 正式分配要求在役海外车辆及已确认的托位/载重/容积,以及海外司机;司机按预计任务结束时间重新核验且验证证照文件。 + 发车预览与实际发车重新核对司机当前海外区域和身份;分配后档案变更不能沿用原分配时的资格结果。 + 一个车辆或司机同时只能占用一个未释放车次;当前不提供多个未来时段的并行预约。 + 改派先取得新资源,成功后释放旧资源;整次写入与历史处于同一事务。 + 司机预计结束包含承担的返程/转场;修改预计结束须重新核验司机,所有写命令使用recordVersion。 + 普通计划接口不提供修改发车或签收状态的入口;PDA统一发车,结案检查所有客户派送终态及实发数量。 + 货物结案不释放车辆或司机;结束各资源任务独立提交实际结束时间、地点和原因,车辆另确认可用/维修/停用/已归还状态。 + 结案和结束任务命令携带版本及幂等键;结束司机任务不要求其证照仍有效,也不自动恢复其派车资格。 + 关联要求已分配车和司机,客户排车单同起运仓且未开始拣货;可跨客户关联但不改变客户数据归属。 + 同一排车单唯一关联一个车次;关联、改派、解除及尚未执行的派送单车/司机快照同步使用同一事务。 + 关联后子排车不能独立编辑、取消或单独发车;取消车次前须解除关联,已开工不允许直接解除或变更路线。 + 发车前允许在车次统一换车、换司机或调整时间,保留全部拣货事实与客户归属;已确认装载须能容纳于替换车辆。 + 改派或调整计划使原装载确认失效,发车前须重新确认;不支持用计划接口处理在途换车或撤销已执行货物。 + 关联只建立执行单据关系,不代表实际装载量、容量核验或审核已完成。 + 每张客户排车单调度审核通过后允许新拣货;整车确认实际装载并通过理货审核后允许发车。 + 审核仅通过或打回,通过意见选填、打回原因必填;无需额外提交或不同审核人。 + 换车换司机、修改路线或时间使调度与理货审核待审;仅修改备注保留审核及装载。 + 关联变更、重新确认装载或重新审核客户排车单使理货审核待审;保留全部拣货事实及历史审核快照。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/trips: + get: + summary: 分页查询车次 + description: '权限:platform:fleet:trip:list' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}, description: 车次号/车牌/司机} + - {name: status, in: query, schema: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}} + - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 统一业务响应,data包含records/page/size/total/pages;不逐行请求司机详情 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Trip'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + post: + summary: 幂等创建车次计划 + description: '权限:platform:fleet:trip:create;起运仓使用公共有效海外仓目录;不推断GPS路线。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Create'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/customer-options: + get: + summary: 查询有效委托客户候选 + description: '权限:platform:fleet:trip:list;读取IAM当前客户主档;不使用采购历史或允许输入不存在的客户。' + parameters: [{name: keyword, in: query, schema: {type: string}}] + responses: {'200': {description: 统一响应data为客户id/code/name/contactName/contactPhone数组}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 查询本车外接询价与承运预订 + description: '权限:platform:fleet:trip:list;返回tripRecordVersion及records;约定金额不是应收或实收。' + responses: {'200': {description: 统一响应data为版本及Booking数组}} + post: + summary: 登记外接询价 + description: '权限:platform:fleet:trip:booking-create;DRAFT不占位。客户由IAM校验,报价和预计载荷可待确认再补;起点固定本起运仓,卸货站为既有路线站点。相同创建键与内容重放原预订。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingCreate'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + put: + summary: 编辑尚未确认的外接询价 + description: '权限:platform:fleet:trip:booking-edit;仅DRAFT;客户与预订业务编号不变,更新前核对车次及预订版本。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingUpdate'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/confirm: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 确认外接承运并占用计划容量 + description: '权限:platform:fleet:trip:booking-confirm;父车次锁内核对来源仍有效的自有货容量计划及全部已确认预订;报价/币种/预计托位重量体积完整。幂等重放返回当前预订事实,不重复占位;确认不代表已收货、已发运或已入财务。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}, '409': {description: 版本、资格或容量不满足}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/incoming: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + get: + summary: 查询外接来货及实际测量依据 + description: '权限:platform:fleet:trip:list;固定预订客户范围,返回tripRecordVersion、recordVersion与incoming;尚未登记时incoming为null,不逐行预取。' + responses: {'200': {description: 统一响应data为版本及IncomingEvidence}} + post: + summary: 为已确认承运生成真实来货单 + description: '权限:platform:fleet:trip:booking-edit;复用仓储转运创建/审核,在同一事务建立客户自送来货并关联预订;不写库存,不冒充实收;已有来货拒绝重复创建,同键同内容重放。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingIncoming'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/receipt-confirmation: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 运营确认委托方实收货量与运费 + description: '权限:platform:fleet:trip:booking-confirm;实际来货已完成测量,receiptHash和实托集合一致,重新核算容量和约定金额,引用真实收货证据并确认原来货流程;不重复入库。差异必须显式确认,超容量回滚。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingAcceptReceipt'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/dispatch: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 将外接实收货物关联客户排车 + description: '权限:同时要求platform:fleet:trip:booking-edit和platform:fleet:trip:edit;排车必须同客户同仓,完整包含预订来货的实收托盘与数量且目的地一致;原子绑定共享车次,防止绕过预订挂到其他车次。解除未开工关联后回到RECEIVED并重新预留容量,已拣货不允许直接解除。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingBindDispatch'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/cancel: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 取消未执行的外接预订并释放容量 + description: '权限:platform:fleet:trip:booking-cancel;仅计划中车次的DRAFT/CONFIRMED/RECEIVED;只释放承运预留,不删除来货或库存;同键同内容重放不重复释放,历史保留报价及承运确认。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 查询车次详情和当前司机资格 + description: '权限:platform:fleet:trip:list;司机资格仅是详情预览,实际命令必须重新核验。' + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/{dispatchNo}/review: + parameters: + - {$ref: '#/components/parameters/TripNo'} + - {name: dispatchNo, in: path, required: true, schema: {type: string}} + post: + summary: 通过或打回单张客户排车调度审核 + description: '权限:platform:fleet:trip:dispatch-review;仅PLANNED;审核所属车次内的排车单,成功后理货审核待审;审批不扣库存。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Review'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}, '409': {description: 版本、关联或资格冲突}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/loading-review: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 通过或打回整车理货审核 + description: '权限:platform:fleet:trip:loading-review;仅PLANNED;通过要求全部调度审核、拣货和实际装载确认已完成,当前司机资格有效。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Review'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}, '409': {description: 版本、调度审核、装载或资格冲突}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/plan: + parameters: [{$ref: '#/components/parameters/TripNo'}] + put: + summary: 修改计划时间和路线 + description: '权限:platform:fleet:trip:edit;仅PLANNED;司机预计结束变更重新核验;原因必填。已开始拣货时路线须完全一致,仅可调整时间和备注;调整时间使审核及装载确认失效,仅修改备注保留。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Plan'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/assignment: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 分配或更换车辆与司机 + description: '权限:platform:fleet:trip:assign;仅PLANNED且全部关联单未发车、未出库;允许已拣货时统一改派并同步派送快照,保留拣货和到达时间。替换车辆须满足已确认装载容量,成功后清除原装载确认并重新核验;行锁和唯一占用键防重复派车,资格校验不得通过人工审核绕过。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, vehicleId, driverId, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + vehicleId: {type: string, pattern: '^[1-9][0-9]*$'} + driverId: {type: string, pattern: '^[1-9][0-9]*$'} + reason: {type: string, minLength: 1, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/cancel: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 取消计划并释放车和司机 + description: '权限:platform:fleet:trip:cancel;仅PLANNED;保留车次快照和历史。已执行货物后续须按实际执行状态校验,不允许取消抹除作业事实。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/bind: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 将客户排车单关联到车次 + description: '权限:platform:fleet:trip:edit;按业务单号解析客户归属,同仓且未开工;关联和车/司机快照同步原子提交。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/unbind: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 解除尚未执行的客户排车单关联 + description: '权限:platform:fleet:trip:edit;清除排车及派送单当前车辆司机快照,历史保留;已拣货禁止直接解除。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/history: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 分页读取车次操作历史 + description: '权限:platform:fleet:trip:list;版本倒序;不将历史快照当作当前资格。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 统一响应;data为PageResult,records项包含recordVersion/action/reason/operatorId/occurredAt/snapshot,snapshot包含trip、带review的dispatches、capacityPlan、confirmedLoad、completion及loadingReview、externalBookings不可变快照 + /api/platform/v1/last-leg/fleet/trips/{tripNo}/capacity-plan: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 读取拣货前计划载荷和逐段余位 + description: '权限:platform:fleet:trip:list;按当前关联货物返回manifestHash;planValid为false表示尚未核对或来源已经变化,不能据旧计划承诺外接运力。' + responses: + '200': {description: 统一响应data为Planning, content: {application/json: {schema: {$ref: '#/components/schemas/PlanningEnvelope'}}}} + post: + summary: 核对本车货物计划载荷 + description: '权限:platform:fleet:trip:edit;仅PLANNED;允许尚未拣货或审核,通过实托去重及逐段容量检查。保存计划不推进审核、库存或发车,不代替实际装载确认;调整计划/资源/关联使容量计划失效,纯备注保留。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmLoad'}}}} + responses: + '200': {description: 已保存容量计划, content: {application/json: {schema: {$ref: '#/components/schemas/PlanningEnvelope'}}}} + '409': {description: 版本、来源变化或容量超限} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/loading: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 读取整车实物装载核对依据 + description: '权限:platform:fleet:trip:list;返回各客户货物组成、manifestHash和当前装载确认,实际重量不得使用计费材积重。' + responses: + '200': {description: 统一响应data为Loading, content: {application/json: {schema: {$ref: '#/components/schemas/LoadingEnvelope'}}}} + post: + summary: 确认整车实际装载及逐段容量 + description: '权限:platform:fleet:trip:edit;全部客户拣货核平且转派送后确认;同一实物托盘只计量一次。货物hash或车次版本变化拒绝,托盘卸货站须匹配原货物目的地。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmLoad'}}}} + responses: + '200': {description: 已确认装载快照,统一响应data为Loading, content: {application/json: {schema: {$ref: '#/components/schemas/LoadingEnvelope'}}}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/completion: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 核对各客户货物是否全部结束履约 + description: '权限:platform:fleet:trip:list;返回tripNo、recordVersion、canComplete、blockReason、outcomes。签收以signedAt为准,退回须有RETURN_COMPLETED操作事实,不以updatedAt代替实际终态时间。' + responses: + '200': {description: 统一响应data包含结案检查结果与逐张派送Outcome, content: {application/json: {schema: {$ref: '#/components/schemas/CompletionEnvelope'}}}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/complete: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 核对全部货物并结案车次 + description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、不释放资源;同键同内容重放,旧版本或不同内容拒绝。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Complete'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/resources/release: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 单独结束车辆或司机本次任务并释放占用 + description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。车辆状态与释放同事务更新;司机可继续返程而保持占用。原请求重放不释放后来另一个车次的占用。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Release'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + BookingNo: + name: bookingNo + in: path + required: true + schema: {type: string} + TripNo: {name: tripNo, in: path, required: true, schema: {type: string}} + responses: + BookingResult: + description: 统一响应data含tripRecordVersion、booking及replayed;客户端使用当前状态,重放不覆盖后续已发生的取消 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripRecordVersion: {type: integer, format: int64} + booking: {$ref: '#/components/schemas/Booking'} + replayed: {type: boolean} + Detail: + description: 统一响应,code为200代表成功,data为车次详情;业务冲突使用业务错误,不降级为成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + trip: {$ref: '#/components/schemas/Trip'} + dispatches: {type: array, items: {$ref: '#/components/schemas/DispatchBinding'}} + loadingReview: {$ref: '#/components/schemas/ReviewState'} + driverQualification: + type: object + nullable: true + description: 未分配司机为null;字段与fleet-drivers-v1的Qualification一致 + properties: + canDispatch: {type: boolean} + expiringSoon: {type: boolean} + reason: {type: string} + licenseExpiresOn: {type: string, format: date, nullable: true} + schemas: + BookingCargo: + type: object + required: [destinationKey, packageCount] + properties: + destinationKey: {type: string, maxLength: 160} + cargoName: {type: string, nullable: true, maxLength: 255} + packageCount: {type: integer, minimum: 1} + palletSpaces: {type: integer, nullable: true, minimum: 1} + weightKg: {type: number, nullable: true, minimum: 0.001} + volumeCbm: {type: number, nullable: true, minimum: 0.001} + priceBasis: {type: string, nullable: true, enum: [PALLET, ORDER], description: 为空时默认PALLET} + unitPrice: {type: number, nullable: true, minimum: 0, multipleOf: 0.01} + currency: {type: string, nullable: true, maxLength: 3} + contactName: {type: string, nullable: true, maxLength: 128} + contactPhone: {type: string, nullable: true, maxLength: 128} + remark: {type: string, nullable: true, maxLength: 1000} + IncomingEvidence: + type: object + properties: + incomingNo: {type: string} + inboundCode: {type: string} + warehouseId: {type: string} + destinationKey: {type: string} + status: {type: string} + receiptHash: {type: string, description: 实托、数量、实测值及来源匹配摘要,不包含可变作业状态} + receivedPackages: {type: integer} + pallets: + type: array + items: + type: object + properties: + warehousePalletId: {type: string} + palletNo: {type: string} + packages: {type: integer} + weightKg: {type: number, nullable: true} + volumeCbm: {type: number, nullable: true} + BookingIncoming: + allOf: + - {$ref: '#/components/schemas/BookingCommand'} + - properties: + estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 缺省为计划发车前2小时的预计值} + deliveryInstructions: {type: string, nullable: true, maxLength: 1000} + privateAddress: {type: string, nullable: true, maxLength: 1000} + postalCode: {type: string, nullable: true, maxLength: 32} + recipientName: {type: string, nullable: true, maxLength: 128} + recipientPhone: {type: string, nullable: true, maxLength: 64} + BookingPalletSpaces: + type: object + required: [warehousePalletId, palletSpaces] + properties: + warehousePalletId: {type: string} + palletSpaces: {type: integer, minimum: 1} + BookingAcceptReceipt: + allOf: + - {$ref: '#/components/schemas/BookingCommand'} + - required: [receiptHash, pallets, unitPrice] + properties: + receiptHash: {type: string} + pallets: {type: array, minItems: 1, items: {$ref: '#/components/schemas/BookingPalletSpaces'}} + unitPrice: {type: number, minimum: 0, multipleOf: 0.01} + BookingBindDispatch: + allOf: + - {$ref: '#/components/schemas/BookingCommand'} + - required: [dispatchNo] + properties: + dispatchNo: {type: string, maxLength: 64} + BookingCreate: + type: object + required: [tripRecordVersion, customerId, cargo, idempotencyKey] + properties: + tripRecordVersion: {type: integer, format: int64, minimum: 0} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + cargo: {$ref: '#/components/schemas/BookingCargo'} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + BookingUpdate: + type: object + required: [tripRecordVersion, recordVersion, cargo] + properties: + tripRecordVersion: {type: integer, format: int64, minimum: 0} + recordVersion: {type: integer, format: int64, minimum: 0} + cargo: {$ref: '#/components/schemas/BookingCargo'} + BookingCommand: + type: object + required: [tripRecordVersion, recordVersion, idempotencyKey] + properties: + tripRecordVersion: {type: integer, format: int64, minimum: 0} + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, nullable: true, maxLength: 1000} + Booking: + type: object + properties: + bookingNo: {type: string} + tripNo: {type: string} + customerId: {type: string} + customerCode: {type: string} + customerName: {type: string} + status: {type: string, enum: [DRAFT, CONFIRMED, RECEIVED, BOUND, CANCELLED]} + cargo: {$ref: '#/components/schemas/BookingCargo'} + destinationLabel: {type: string} + agreedAmount: {type: number, nullable: true, description: 约定运费,非财务应收或实收} + incomingNo: {type: string, nullable: true} + dispatchNo: {type: string, nullable: true} + receipt: + type: object + nullable: true + properties: + source: {$ref: '#/components/schemas/IncomingEvidence'} + pallets: {type: array, items: {$ref: '#/components/schemas/BookingPalletSpaces'}} + operatorId: {type: string} + confirmedAt: {type: string, format: date-time} + recordVersion: {type: integer, format: int64} + updatedAt: {type: string, format: date-time} + Outcome: + type: object + description: 本车原运输的结案依据;已签收后另发起退货仍保留本次运输DELIVERED及原signedAt,不修改后续退货单据状态。 + properties: + dispatchNo: {type: string} + deliveryNo: {type: string} + customerId: {type: string} + destination: {type: string, nullable: true} + status: {type: string} + packages: {type: integer} + terminalAt: {type: string, format: date-time, nullable: true} + CompletionEnvelope: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripNo: {type: string} + recordVersion: {type: integer, format: int64} + canComplete: {type: boolean} + blockReason: {type: string, nullable: true} + outcomes: {type: array, items: {$ref: '#/components/schemas/Outcome'}} + Complete: + type: object + required: [recordVersion, idempotencyKey, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, minLength: 1, maxLength: 1000} + Release: + type: object + required: [recordVersion, resourceKind, actualEndAt, endLocation, idempotencyKey, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + resourceKind: {type: string, enum: [VEHICLE, DRIVER]} + actualEndAt: {type: string, format: date-time} + endLocation: {type: string, minLength: 1, maxLength: 500} + vehicleStatus: {type: string, nullable: true, enum: [IN_SERVICE, MAINTENANCE, INACTIVE, RETURNED], description: 结束车辆任务必填,结束司机任务必须为空} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, minLength: 1, maxLength: 1000} + ReleaseInfo: + type: object + nullable: true + properties: + actualEndAt: {type: string, format: date-time} + endLocation: {type: string} + vehicleStatus: {type: string, nullable: true, description: 结束本次车辆任务时的状态快照,不代表车辆此后的实时可派状态} + LoadingEnvelope: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripNo: {type: string} + recordVersion: {type: integer, format: int64} + manifestHash: {type: string} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + blockReason: {type: string, nullable: true} + confirmedLoad: + type: object + nullable: true + properties: + manifestHash: {type: string} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + pallets: {type: array, items: {$ref: '#/components/schemas/Measurement'}} + segments: {type: array, items: {type: object}, description: 逐段fromIndex/toIndex及occupied/remaining托位、weightKg、volumeCbm} + operatorId: {type: string} + confirmedAt: {type: string, format: date-time} + Cargo: + type: object + properties: + dispatchNo: {type: string} + customerId: {type: string} + dispatchItemId: {type: string} + warehousePalletId: {type: string, nullable: true} + palletNo: {type: string, nullable: true} + packages: {type: integer} + destinationKey: {type: string, nullable: true, description: 原货物目的地稳定匹配键,选择路线站点时透传} + destinationLabel: {type: string, nullable: true} + Measurement: + type: object + required: [warehousePalletId, palletSpaces, weightKg, volumeCbm, deliveryIndex] + properties: + warehousePalletId: {type: string} + palletSpaces: {type: integer, minimum: 1} + weightKg: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} + volumeCbm: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} + deliveryIndex: {type: integer, minimum: 1, description: 卸货站索引,起运仓固定为0} + PlanningEnvelope: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripNo: {type: string} + recordVersion: {type: integer, format: int64} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + manifestHash: {type: string} + planValid: {type: boolean, description: 计划已经核对且来源hash仍一致} + availableSegments: {type: array, items: {$ref: '#/components/schemas/Segment'}, description: 当前自有货计划加已确认外接预订后的逐段占用与余位;计划失效返回空数组} + blockReason: {type: string, nullable: true} + capacityPlan: + type: object + nullable: true + description: 计划值,不能作为实际装载确认 + properties: + manifestHash: {type: string} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + pallets: {type: array, items: {$ref: '#/components/schemas/Measurement'}} + segments: {type: array, items: {$ref: '#/components/schemas/Segment'}} + operatorId: {type: string} + confirmedAt: {type: string, format: date-time} + Segment: + type: object + properties: + fromIndex: {type: integer} + toIndex: {type: integer} + occupied: {$ref: '#/components/schemas/CapacityLoad'} + remaining: {$ref: '#/components/schemas/CapacityLoad'} + CapacityLoad: + type: object + properties: + palletSpaces: {type: integer} + weightKg: {type: number} + volumeCbm: {type: number} + ConfirmLoad: + type: object + required: [recordVersion, manifestHash, pallets, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + manifestHash: {type: string, pattern: '^[0-9a-f]{64}$'} + pallets: {type: array, minItems: 1, maxItems: 1000, items: {$ref: '#/components/schemas/Measurement'}} + reason: {type: string, minLength: 1, maxLength: 1000} + BindingCommand: + type: object + required: [recordVersion, dispatchNo, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + dispatchNo: {type: string, minLength: 1, maxLength: 64} + reason: {type: string, minLength: 1, maxLength: 1000} + Review: + type: object + required: [recordVersion, decision] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + decision: {type: string, enum: [APPROVED, REJECTED]} + reason: {type: string, nullable: true, maxLength: 1000, description: 通过选填;打回必填} + ReviewState: + type: object + required: [status] + properties: + status: {type: string, enum: [PENDING, APPROVED, REJECTED]} + reason: {type: string, nullable: true} + reviewerId: {type: string, nullable: true} + reviewedAt: {type: string, format: date-time, nullable: true} + recordVersion: {type: integer, format: int64, nullable: true, description: 本次审核所核对的车次版本} + DispatchBinding: + type: object + properties: + dispatchNo: {type: string} + customerId: {type: string, description: 排车单自身客户归属,由服务端解析} + packageCount: {type: integer, description: 关联时待发运箱数,不是实物托位数} + boundBy: {type: string} + boundAt: {type: string, format: date-time} + review: {$ref: '#/components/schemas/ReviewState'} + Envelope: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: {type: object, nullable: true} + Stop: + type: object + required: [name] + properties: + name: {type: string, minLength: 1, maxLength: 128} + address: {type: string, nullable: true, maxLength: 500} + destinationKey: {type: string, nullable: true, maxLength: 160, description: 卸货站匹配Loading货物的destinationKey;未配置时可以保存计划但不能确认装载} + Create: + type: object + required: [warehouseId, plannedDepartureAt, expectedDriverEndAt, stops, idempotencyKey] + properties: + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}} + remark: {type: string, nullable: true, maxLength: 1000} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + Plan: + type: object + required: [recordVersion, plannedDepartureAt, expectedDriverEndAt, stops, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}} + remark: {type: string, nullable: true, maxLength: 1000} + reason: {type: string, minLength: 1, maxLength: 1000} + Trip: + type: object + properties: + tripNo: {type: string} + status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]} + warehouseId: {type: string} + warehouseCode: {type: string} + warehouseName: {type: string} + vehicleId: {type: string, nullable: true} + vehiclePlate: {type: string, nullable: true} + driverId: {type: string, nullable: true} + driverName: {type: string, nullable: true} + driverPhone: {type: string, nullable: true} + maxPalletSpaces: {type: integer, nullable: true} + maxLoadKg: {type: number, nullable: true} + maxVolumeCbm: {type: number, nullable: true} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + departedAt: {type: string, format: date-time, nullable: true} + completedAt: {type: string, format: date-time, nullable: true} + stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} + remark: {type: string, nullable: true} + recordVersion: {type: integer, format: int64} + updatedAt: {type: string, format: date-time} + vehicleRelease: {$ref: '#/components/schemas/ReleaseInfo'} + driverRelease: {$ref: '#/components/schemas/ReleaseInfo'} diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml new file mode 100644 index 000000000..6ee0ea42c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml @@ -0,0 +1,175 @@ +openapi: 3.0.3 +info: + title: 车辆运维记录 + version: 1.0.0 + description: >- + 平台车辆运维事实,支持保养、加油、维修、紧急故障、应急救援和异常。每条记录固定属于一辆车,使用VR业务编号;读取、修改、照片和历史均校验路径中的车辆归属。 + 原始里程必须明确KM或MI,加油量必须明确L、US_GAL或IMP_GAL;不自动推测或换算,不更新车辆主档公里数。 + 核验状态是人工记录的PENDING或VERIFIED,不代表财务审批或车辆可派,不自动产生账单、付款或变更车辆状态。 + 创建按车辆、操作者和幂等键去重;更正须版本及原因。照片上传按记录与幂等键去重并核对内容摘要,丢失响应可原样重试。 + 照片最多20张,每张非空JPG/PNG且最多10MiB;File按平台0、FLEET_VEHICLE_RECORD_PHOTO和记录内部主键复合归属,验证内容签名。 + 维修和救援项目、地点独立保存;处理中或已完成须有处理人,异常须有明确分类。 + 费用只是运维申报金额,填金额则必须填有效币种;未填金额和零金额有区别,不生成应付或付款。 + 维修/救援paymentStatus当前固定UNLINKED表示未关联财务,不得由processingStatus推定为已付款,其他类型为空。 + 不提供物理删除;编辑与上传历史同事务不可变保存。本接口不提供财务结算或车辆占用/状态自动变更。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records: + parameters: [{$ref: '#/components/parameters/Vehicle'}] + get: + summary: 分页查询当前车辆运维记录 + description: '权限:platform:overseas-master-data:vehicle:list;按发生时间倒序,列表包含已关联照片元数据,不读取图片内容。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: recordType, in: query, schema: {type: string, enum: [MAINTENANCE, FUEL, REPAIR, BREAKDOWN, RESCUE, EXCEPTION]}} + responses: + '200': {description: '统一响应data为records/page/size/total/pages,records元素为Record'} + post: + summary: 新增记录 + description: '权限:platform:overseas-master-data:vehicle:record-create;同键同内容返回原记录,不同内容冲突。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey, details] + properties: + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + details: {$ref: '#/components/schemas/Draft'} + responses: + '200': {$ref: '#/components/responses/Record'} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}: + parameters: [{$ref: '#/components/parameters/Vehicle'}, {$ref: '#/components/parameters/RecordNumber'}] + get: + summary: 读取记录详情 + description: '权限:platform:overseas-master-data:vehicle:list;跨车辆记录返回404,不泄露其他车辆数据。' + responses: + '200': {$ref: '#/components/responses/Record'} + put: + summary: 更正记录 + description: '权限:platform:overseas-master-data:vehicle:record-edit;类型及车辆不可更换,保留历史。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, reason, details] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 1000} + details: {$ref: '#/components/schemas/Draft'} + responses: + '200': {$ref: '#/components/responses/Record'} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/files: + parameters: [{$ref: '#/components/parameters/Vehicle'}, {$ref: '#/components/parameters/RecordNumber'}] + post: + summary: 上传并关联运维图片 + description: '权限:platform:overseas-master-data:vehicle:record-upload;记录保存成功后再上传,失败重试不得重复建记录。' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [recordVersion, idempotencyKey, file] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + file: {type: string, format: binary} + responses: + '200': {$ref: '#/components/responses/Record'} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/files/{fileId}: + parameters: + - {$ref: '#/components/parameters/Vehicle'} + - {$ref: '#/components/parameters/RecordNumber'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + get: + summary: 读取当前记录已关联图片 + description: '权限:platform:overseas-master-data:vehicle:list;先校验记录关联,再校验File复合归属;no-store和nosniff,不返回存储路径。' + responses: + '200': {description: 图片字节, content: {image/png: {schema: {type: string, format: binary}}, image/jpeg: {schema: {type: string, format: binary}}}} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/history: + parameters: [{$ref: '#/components/parameters/Vehicle'}, {$ref: '#/components/parameters/RecordNumber'}] + get: + summary: 分页读取不可变修改历史 + description: '权限:platform:overseas-master-data:vehicle:list;版本倒序,仅按需读取。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': {description: '统一响应data为分页对象;records包含recordVersion/action/reason/operatorId/occurredAt/snapshot,snapshot为Record'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + Vehicle: {name: vehicleId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + RecordNumber: {name: recordNo, in: path, required: true, schema: {type: string}} + responses: + Record: + description: 统一业务响应;失败保留code、message及诊断标识 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/Record'} + schemas: + Draft: + type: object + required: [recordType, occurredAt] + oneOf: + - properties: {recordType: {enum: [MAINTENANCE]}} + required: [distanceUnit, odometer, verificationStatus, lastServiceOdometer, nextServiceOdometer] + - properties: {recordType: {enum: [FUEL]}} + required: [distanceUnit, odometer, verificationStatus, fuelQuantity, fuelUnit] + - properties: {recordType: {enum: [REPAIR, BREAKDOWN, RESCUE, EXCEPTION]}} + required: [incident] + properties: + recordType: {type: string, enum: [MAINTENANCE, FUEL, REPAIR, BREAKDOWN, RESCUE, EXCEPTION]} + occurredAt: {type: string, format: date-time, description: 带偏移或Z的实际发生时间,不晚于当前时间,保存UTC} + distanceUnit: {type: string, nullable: true, enum: [KM, MI], description: 保养加油必填;其他类型必须为空} + odometer: {type: number, nullable: true, minimum: 0, multipleOf: 0.001, description: 保养加油必填;其他类型必须为空} + lastServiceOdometer: {type: number, nullable: true, minimum: 0, description: 保养必填,不大于记录时里程;加油必须为空} + nextServiceOdometer: {type: number, nullable: true, minimum: 0, description: 保养必填且大于上次保养里程,可低于当前里程表示已超保养里程;不默认一万迈} + fuelQuantity: {type: number, nullable: true, minimum: 0, exclusiveMinimum: true, description: 加油必填正数;保养必须为空} + fuelUnit: {type: string, nullable: true, enum: [L, US_GAL, IMP_GAL], description: 加油必填明确单位;保养必须为空} + verificationStatus: {type: string, nullable: true, enum: [PENDING, VERIFIED], description: 保养加油必填;其他类型必须为空,不能混作处理状态} + remark: {type: string, nullable: true, maxLength: 1000} + incident: {$ref: '#/components/schemas/Incident'} + Incident: + type: object + nullable: true + description: 维修/故障/救援/异常必填;保养加油必须为空,并禁止混入里程、加油或核验字段 + required: [description, processingStatus] + properties: + description: {type: string, minLength: 1, maxLength: 2000} + item: {type: string, nullable: true, maxLength: 256, description: 维修或救援项目,此两类必填;其他类型为空} + place: {type: string, nullable: true, maxLength: 512, description: 维修或救援地点,此两类必填;其他类型为空} + handler: {type: string, nullable: true, maxLength: 128, description: 实际处理人;处理中或完成时必填,与系统操作人分别保留} + processingStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]} + exceptionType: {type: string, nullable: true, enum: [ROUTE, TIMEOUT, DELIVERY, VEHICLE_BREAKDOWN], description: 异常记录必填;其他类型为空} + reportedCost: {type: number, nullable: true, minimum: 0, multipleOf: 0.01, maximum: 999999999999.99, description: 仅维修/救援允许;空为未登记,零为已登记零费用,不表示付款} + currency: {type: string, nullable: true, pattern: '^[A-Z]{3}$', description: 金额非空时必填有效币种;未填金额时为空,不自动换算} + File: + type: object + properties: + fileId: {type: string} + fileName: {type: string} + contentType: {type: string, enum: [image/jpeg, image/png]} + sizeBytes: {type: integer, format: int64} + Record: + type: object + properties: + recordNo: {type: string} + vehicleId: {type: string} + vehiclePlate: {type: string, description: 建档时车牌快照} + details: {$ref: '#/components/schemas/Draft'} + files: {type: array, items: {$ref: '#/components/schemas/File'}} + recordVersion: {type: integer, format: int64, minimum: 0} + updatedBy: {type: string} + updatedAt: {type: string, format: date-time} + paymentStatus: {type: string, nullable: true, readOnly: true, enum: [UNLINKED], description: 维修/救援未关联财务时为UNLINKED,其余为空;不可用处理完成推定已付款} 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 9f7836de1..c91b76114 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -600,14 +600,14 @@ paths: '404': { description: 排车单不存在 } put: summary: 编辑待发车排车单 - description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。 + description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。已关联共享车次时拒绝独立编辑,须先按车次执行状态解除关联。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchUpdate' } } } } responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单不存在 }, '409': { description: 排车单已执行或不可编辑 } } delete: summary: 取消待发车排车单 - description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。 + description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。已关联共享车次必须先解除关联,不能单独取消破坏整车计划。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } } @@ -650,7 +650,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}: delete: summary: 移除已排车托盘 - description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。已关联共享车次时须先解除关联。 security: [{ bearerAuth: [] }] parameters: - { name: dispatchNo, in: path, required: true, schema: { type: string } } @@ -715,10 +715,82 @@ paths: - { name: draftToken, in: path, required: true, schema: { type: string, maxLength: 64 } } - { name: containerId, in: path, required: true, schema: { type: string } } responses: { '200': { description: 空车厢删除成功 }, '409': { description: 车厢仍绑定托盘或草稿状态冲突 } } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/fee-coverage: + get: + summary: 按实际发运来源查询原头程费用 + description: 权限platform:overseas-warehouse:dispatch:list;只接收排车号,客户由服务端解析。按实物库存与本次已拣数量还原各票来源并核平排车箱数;合托不使用单一汇总入仓码代替全部来源。sourceComplete=false时不返回费用候选。Finance批量查询仅返回各票该客户原入仓单的已审核有效应收最新版本,存在来源不等于任何服务已包含,查询不会免收或新增费用。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 来源核对结果与原费用候选 + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + dispatchNo: { type: string } + sourceComplete: { type: boolean } + blockReason: { type: string, nullable: true } + cargoHash: { type: string } + cargo: + type: array + items: + type: object + properties: + dispatchItemId: { type: string } + warehousePalletId: { type: string } + palletNo: { type: string, nullable: true } + orderId: { type: string } + sourceRegion: { type: string, nullable: true } + sourceInboundId: { type: string, nullable: true } + inboundCode: { type: string, nullable: true } + packages: { type: integer } + dispatchInboundCode: { type: string, nullable: true, description: 排车来源业务码;不能代替实物拆分后的各票inboundCode } + feeSources: + type: array + items: + type: object + properties: + financeFeeId: { type: string } + sourceFeeId: { type: string } + sourceType: { type: string } + sourceVersion: { type: integer } + customerId: { type: string } + sourceInboundId: { type: string } + inboundCode: { type: string, nullable: true } + feeItemCode: { type: string } + feeItemName: { type: string } + currency: { type: string } + amount: { type: number } + sourceHash: { type: string } + status: { type: string, enum: [AVAILABLE, SETTLED] } + '404': { description: 排车单不存在 } + '503': { description: Finance来源查询不可用或返回范围不一致 } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/external-freight: + get: + summary: 读取本次排车的外接实收约定运费 + description: 权限 platform:overseas-warehouse:dispatch:list;客户从排车单解析。逐票核对真实来货、车次关联、目的地及实收数量,返回不可变报价版本与货物摘要;查询不发布费用。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 约定运费与是否仍需填写其余货物派送费用 + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/ExternalFreightPreview' } + '404': { description: 排车单不存在 } + '409': { description: 外接实收或排车关联不一致 } /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: post: summary: 排车转出库与派送单 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车重复调用返回全部既有单据。派送费用只发布一次,避免按拆分派送单重复计费。 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车只有业务请求内容与首次保存一致才返回既有单据,费用、计划时间、装载或其他字段变化均拒绝,不因更换幂等键绕过。派送费用只发布一次,费用更正使用正式来源调整流程。已关联车次时新请求不得覆盖车次维护的车牌、司机和电话;原请求重放不改写后续已同步的车次快照。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } @@ -766,7 +838,7 @@ paths: /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: post: summary: 取消未执行出库单 - description: 权限 platform:overseas-warehouse:outbound:cancel;只按全局唯一 outboundNo 锁定并解析客户范围,不接收 customerId。仅未开始拣货/出库的单据可取消,重复取消幂等成功。 + description: 权限 platform:overseas-warehouse:outbound:cancel;操作人取认证身份,只按全局唯一outboundNo解析客户,不接收customerId。关联排车按dispatch→outbound顺序锁定,已拣货/发车拒绝,仍绑定车次时须先解除;取消出库不自动取消原排车占用,后续在排车列表取消。当前已审核有效的DELIVERY_FEE应收按原来源版本申请作废,装车/买约/应付等其他费用保留核对,物流取消不代表账务作废成功。取消原因完整保存在派送操作记录,重复取消幂等成功。 security: [{ bearerAuth: [] }] parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundCancel' } } } } @@ -783,7 +855,7 @@ paths: description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓 }, '404': { description: 派送单不存在 } } + responses: { '200': { description: '派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓;fees附reviewStatus、sourceStatus、voidStatus(NOT_REQUESTED/PENDING_FINANCE/FINANCE_ATTENTION/CONFIRMED),不得把待财务确认或待处理显示为已作废。feeCoverage为可空的原费用服务覆盖确认快照,只包含当前派送所属货物、引用的原费用版本及服务、确认人和时间;不代表新增收费或原费用已付款。' }, '404': { description: 派送单不存在 } } /api/platform/v1/last-leg/exception-orders: get: summary: 分页查询异常订单 @@ -983,7 +1055,7 @@ paths: responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } } post: summary: 客户从自己的排车单创建派送单 - description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } @@ -2311,7 +2383,7 @@ components: planNo: { type: string, nullable: true } orderType: { type: string, nullable: true } volumeWeightKg: { type: number, nullable: true } - grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重(千克);按入库计划分配比例汇总,缺失时回退体积重 } + grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重(千克);以实际毛重按入库计划分配比例汇总,缺失时为空,禁止使用体积重替代;正式配载须核验实际装载 } containerNo: { type: string, nullable: true } appointmentAt: { type: string, format: date-time, nullable: true } expectedLoadingAt: { type: string, format: date-time, nullable: true } @@ -2327,8 +2399,6 @@ components: destinationWarehouseName: { type: string } fbaWarehouseCode: { type: string } packageCount: { type: integer } - grossWeightKg: { type: number, description: 排车托盘冻结毛重(千克) } - volumeWeightKg: { type: number, description: 排车托盘冻结材积重(千克) } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } @@ -2396,6 +2466,7 @@ components: estimatedArrivalAt: { type: string, format: date-time, nullable: true } DeliveryTransfer: type: object + description: 外接货必须带入当前externalFreightHash,服务端按实收报价发布对应目的地的派送应收。平台可通过feeCoverage逐票确认原Finance费用所含服务,并传feeCoverageCargoHash;重新核对真实货物、费用ID/版本/hash及客户范围后冻结确认快照。只有全部相关货物已覆盖的服务才能免填对应新增应收,并禁止重复登记该应收;仍可独立登记供应商应付。未覆盖部分收费,原费用不复制入账也不伪造零金额费用。客户接口禁止自行声明feeCoverage。已有成功命令重放保留原覆盖快照。 required: [idempotencyKey, plannedDeliveryAt, deliveryMethod, pickupMethod, loadingType, fees] properties: idempotencyKey: { type: string, minLength: 1, maxLength: 255 } @@ -2413,7 +2484,40 @@ components: appointmentAccount: { type: string, nullable: true, maxLength: 128 } sellerName: { type: string, nullable: true, maxLength: 128 } remark: { type: string, nullable: true, maxLength: 1000 } - fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + fees: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + externalFreightHash: { type: string, nullable: true, maxLength: 64 } + feeCoverageCargoHash: { type: string, nullable: true, maxLength: 64 } + feeCoverage: { type: array, nullable: true, maxItems: 2000, items: { $ref: '#/components/schemas/FeeCoverageSelection' } } + FeeCoverageSelection: + type: object + required: [financeFeeId, sourceVersion, sourceHash, services] + properties: + financeFeeId: { type: string, pattern: '^[1-9][0-9]*$' } + sourceVersion: { type: integer, minimum: 1 } + sourceHash: { type: string } + services: { type: array, minItems: 1, maxItems: 3, items: { type: string, enum: [DELIVERY_FEE, LOADING_FEE, APPOINTMENT_FEE] } } + ExternalFreightPreview: + type: object + required: [snapshotHash, requiresDeliveryFee, quotes] + properties: + snapshotHash: { type: string } + requiresDeliveryFee: { type: boolean } + quotes: + type: array + items: + type: object + properties: + bookingNo: { type: string } + recordVersion: { type: integer, format: int64 } + incomingNo: { type: string } + inboundCode: { type: string, nullable: true } + customerName: { type: string } + destinationKey: { type: string } + currency: { type: string, enum: [CNY, USD] } + amount: { type: number } + quantity: { type: number } + quantityUnit: { type: string, enum: [托位, 单] } + unitPrice: { type: number } DeliveryStatusChange: type: object required: [status, expectedVersion] diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 0e511069e..617b106ca 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -10,7 +10,7 @@ paths: - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } - { in: query, name: businessNo, schema: { type: string } } - { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } } - responses: { '200': { description: 当前版本列表 } } + responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } post: summary: 创建海外业务费用来源草稿 description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 @@ -34,7 +34,7 @@ paths: /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: post: summary: 提交、审核、调整或作废海外业务费用来源 - description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。 + description: 审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml index 06a85196f..fc59b8d73 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -264,10 +264,14 @@ components: VehicleStatus: { type: string, enum: [IN_SERVICE, DISPATCHED, MAINTENANCE, INACTIVE, RETURNED] } VehicleCreate: type: object + description: 托位/载重/容积同时填写正数,或全部留空表示容量尚未确认;未知容量不可用于正式配载,不按车型名称推算。 required: [plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership, status] properties: plateNumber: { type: string, maxLength: 64 } model: { type: string, maxLength: 128 } + maxPalletSpaces: { type: integer, minimum: 1, maximum: 10000, nullable: true } + maxLoadKg: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } + maxVolumeCbm: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } mileageKm: { type: integer, format: int64, minimum: 0 } driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } @@ -278,11 +282,15 @@ components: remark: { type: string, maxLength: 1000, nullable: true } VehicleUpdate: type: object + description: 托位/载重/容积同时填写正数,或全部留空;不能部分设置容量。 required: [recordVersion, plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership] properties: recordVersion: { type: integer, format: int64, minimum: 0 } plateNumber: { type: string, maxLength: 64 } model: { type: string, maxLength: 128 } + maxPalletSpaces: { type: integer, minimum: 1, maximum: 10000, nullable: true } + maxLoadKg: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } + maxVolumeCbm: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } mileageKm: { type: integer, format: int64, minimum: 0, description: 不得小于已有公里码 } driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } 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 99cd5ad41..c0247f9a3 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 @@ -15,12 +15,13 @@ paths: /api/pda/v1/last-leg/dispatches/{dispatchNo}: get: summary: 查询绑定仓排车任务详情 - description: 返回 canDepart 与 departBlockReason;PDA 必须以服务端门禁决定是否开放发车,不得仅按本地拣货数量推断。 + description: 返回 tripNo、tripRecordVersion、tripDispatchCount、driverWarning、canDepart、departBlockReason、canPick 与 pickBlockReason;门禁反映整车所有客户单据、实际装载及当前司机资格,PDA 不得仅按当前子单拣货数量推断。canPick/pickBlockReason仅反映当前客户排车单调度审核,不以其他客户审核结果替代。未关联车次不可拣货或发车;列表按车次复用同次查询的门禁结果。证照30天内到期但覆盖任务时driverWarning提醒且仍允许,预计结束前过期则阻断。 parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 排车托盘、目的仓与库存位置详情 }, '404': { description: 当前绑定仓无此排车任务 } } /api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick: post: summary: 扫码拣取排车托盘包裹 + description: 新请求须当前排车调度审核通过;同键同内容重放已保存结果不重新扣库存。 parameters: - { name: dispatchNo, in: path, required: true, schema: { type: string } } - { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[0-9]+$' } } @@ -29,17 +30,17 @@ paths: /api/pda/v1/last-leg/dispatches/{dispatchNo}/complete: post: summary: 完成排车拣货 - description: 每个要求托盘及全部包裹必须精确完成;不允许部分完成。 + description: 当前排车调度审核通过且每个要求托盘及全部包裹必须精确完成;不允许部分完成。 parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } responses: { '200': { description: READY_TO_DEPART结果 }, '409': { description: 未全部拣货或幂等冲突 } } /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: post: - summary: 排车发车并完成出库门禁 - description: 仅完整拣货且存在待出库单时发车;同事务推进出库与排车状态、订单归属并释放托盘锁,不重复扣减扫码时已扣库存。 + summary: 从排车任务确认所属车次整车发车 + description: 按Token绑定仓锁定车次,校验期望车次版本、车辆占用、全部调度审核、实际装载、整车理货审核及全部客户拣货和待出库单,重新核验司机证照、当前海外区域和身份;区域或身份变化与预览门禁一致阻断。一个事务推进所有子单,任一失败全车回滚。扫码阶段已扣库存不重复扣减;同键同内容重放整车结果,不同内容冲突。DEPARTING仅是同事务内部状态,不对外提交。 parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepartAction' } } } } - responses: { '200': { description: DEPARTED结果;同键同请求返回既有结果 }, '409': { description: 拣货、出库或幂等门禁未满足 } } + responses: { '200': { description: '统一响应data包含tripNo、status=IN_TRANSIT、departedAt、shippedPackages、dispatches数组(dispatchNo/shippedPackages)和replayed;每张子排车状态均为DEPARTED' }, '409': { description: 车次版本、装载、资格、拣货、出库或幂等门禁未满足 } } /api/pda/v1/last-leg/outbound-tasks: get: summary: 查询Token绑定仓的一件代发出库任务 @@ -850,9 +851,10 @@ components: properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } DepartAction: type: object - required: [idempotencyKey] + required: [recordVersion, idempotencyKey] properties: - idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + recordVersion: { type: integer, format: int64, minimum: 0, description: 当前车次版本,来自tripRecordVersion} + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } remark: { type: string, maxLength: 1000, nullable: true } DispatchPick: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java new file mode 100644 index 000000000..b8fedd86a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.client.FinanceFeeCoverageClient; +import com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; +import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeliveryFeeCoverageServiceTest { + final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class); + final DispatchItemMapper items = mock(DispatchItemMapper.class); + final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class); + final FinanceFeeCoverageClient finance = mock(FinanceFeeCoverageClient.class); + final DeliveryFeeCoverageService service = new DeliveryFeeCoverageService(dispatches, items, cargo, finance, + new ObjectMapper().findAndRegisterModules(), "coverage-test-token"); + @BeforeEach void setup() { + var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); dispatch.setDispatchNo("DP-1"); + when(dispatches.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); + var selected = new DispatchItem(); selected.setId(201L); selected.setPackageCount(20); + when(items.findByDispatchOrder("101", 1L)).thenReturn(List.of(selected)); + when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10), source("12", "IN-B", 10))); + } + @Test void batchesBothActualInboundSourcesInsteadOfUsingOneDisplayCode() { + var query = new FinanceFeeCoverageClient.Query(101L, List.of(11L, 12L)); + when(finance.sources("coverage-test-token", query)).thenReturn(ApiResponse.success(List.of(fee(101, 11)))); + var result = service.preview("DP-1"); + assertThat(result.sourceComplete()).isTrue(); assertThat(result.cargo()).hasSize(2); assertThat(result.feeSources()).hasSize(1); + assertThat(result.cargoHash()).hasSize(64); + verify(finance).sources("coverage-test-token", query); verifyNoMoreInteractions(finance); + } + @Test void incompletePhysicalSourcesNeverProduceAFalseCoverageOption() { + when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10))); + var result = service.preview("DP-1"); + assertThat(result.sourceComplete()).isFalse(); assertThat(result.blockReason()).contains("箱数不一致"); + assertThat(result.feeSources()).isEmpty(); verifyNoInteractions(finance); + } + @Test void unavailableOrWrongScopeFinanceResultsFailClosed() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(202, 11)))); + assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("503"); + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 999)))); + assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class); + when(finance.sources(anyString(), any())).thenReturn(null); + assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class); + } + @Test void platformReadonlyEndpointDoesNotAcceptMerchantIdentityOrAClientSuppliedCustomer() throws Exception { + var controller = new PlatformDeliveryFeeCoverageController(service); + var merchant = new AuthenticatedUser(7L, 101L, 101L, 8L, "merchant", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.preview(merchant, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("403"); + assertThatThrownBy(() -> controller.preview(null, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("401"); + var method = PlatformDeliveryFeeCoverageController.class.getMethod("preview", AuthenticatedUser.class, String.class); + assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:overseas-warehouse:dispatch:list"); + verifyNoInteractions(dispatches, items, cargo, finance); + } + @Test void onlyFullyCoveredServicesCanRemoveANewChargeAndTheSnapshotKeepsEveryActualSource() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11), fee(101,12)))); + var preview = service.preview("DP-1"); + var a = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE")); + var partial = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a)), "OP-1", Set.of()); + assertThat(partial.includedServices()).isEmpty(); + assertThat(partial.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::sourceInboundId).containsExactly("12"); + var b = new DeliveryFeeCoverageModels.Selection(712L, 1, fee(101,12).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE")); + var complete = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a,b)), "OP-1", Set.of()); + assertThat(complete.includedServices()).containsExactlyInAnyOrder("DELIVERY_FEE", "LOADING_FEE"); + assertThat(complete.evidence().cargo()).hasSize(2); assertThat(complete.evidence().references()).hasSize(2); + assertThat(complete.evidence().operatorId()).isEqualTo("OP-1"); + } + @Test void staleCargoFeeVersionAndRepeatedCoverageAreRejected() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11)))); + var preview = service.preview("DP-1"); + var selected = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE")); + assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest("old-cargo", List.of(selected)), "OP-1", Set.of())).hasMessageContaining("货物已变化"); + var old = new DeliveryFeeCoverageModels.Selection(711L, 2, selected.sourceHash(), selected.services()); + assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(old)), "OP-1", Set.of())).hasMessageContaining("版本已变化"); + assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selected, selected)), "OP-1", Set.of())).hasMessageContaining("只能引用一项"); + } + static DeliveryRequests.Transfer coverageRequest(String hash, List selections) { + return new ObjectMapper().findAndRegisterModules().convertValue(java.util.Map.of("feeCoverageCargoHash", hash, "feeCoverage", selections), DeliveryRequests.Transfer.class); + } + static DeliveryFeeCoverageModels.Cargo source(String id, String code, long packages) { + return new DeliveryFeeCoverageModels.Cargo(201L, 501L, "PLT-501", Long.valueOf(id), "FIRSTLEG", id, code, packages, "IN-A"); + } + static FinanceFeeCoverageClient.Fee fee(long customer, long inbound) { + return new FinanceFeeCoverageClient.Fee(700L + inbound, 800L + inbound, "INBOUND_FEE", 1, customer, inbound, + "IN-A", "FREIGHT", "原运费", "CNY", BigDecimal.TEN, "sha256:" + "a".repeat(64), "AVAILABLE"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index d3bd1ed4e..b5bcd85d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -34,9 +34,9 @@ class DeliveryLongIdJsonContractTest { deliveryRow(), List.of(), List.of(new DeliveryResponses.DeliveryFeeRow( - FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null)), + FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null, "APPROVED", "ACTIVE", "NOT_REQUESTED")), List.of(new DeliveryResponses.DeliveryOperationRow( - OPERATION_ID, null, "CREATED", "tester", "success"))); + OPERATION_ID, null, "CREATED", "tester", "success")), null); JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(detail)); @@ -67,7 +67,7 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null, + com.baiyue.lastleg.delivery.service.DeliveryDocumentSnapshot.summary(null), null, null, null, null, null, null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 6581d8b52..68df52f74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -17,6 +17,18 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; class DeliverySchemaContractTest { + @Test + void shipmentSnapshotsKeepUnknownPhysicalWeightNullableAcrossBothBaselines() throws Exception { + for (Path path : java.util.List.of(Path.of("src/main/resources/db/lastleg.sql"), Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) { + String sql = Files.readString(path, StandardCharsets.UTF_8); + for (String table : java.util.List.of("ll_dispatch_item", "ll_outbound_order", "ll_outbound_pallet_item", "ll_delivery_order")) { + var matcher = java.util.regex.Pattern.compile("(?is)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(sql); + assertTrue(matcher.find(), table); + String column = "ll_delivery_order".equals(table) ? "total_weight_kg" : "gross_weight_kg"; + assertTrue(java.util.regex.Pattern.compile("(?i)" + column + " DECIMAL\\(18,[34]\\) NULL COMMENT").matcher(matcher.group()).find(), table + " keeps unknown weight NULL"); + } + } + } @Test void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java new file mode 100644 index 000000000..f89490f9b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.delivery; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DispatchFeeCargoMapperTest { + JdbcTemplate jdbc; DispatchFeeCargoMapper mapper; + @BeforeEach void setup() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,pallet_no VARCHAR(64),overseas_warehouse_id VARCHAR(64),plan_pallet_id BIGINT,inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(201,1,'101',501,'PLT-501','WH-1',NULL,'IN-A')"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')"); + jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)"); + var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchFeeCargoMapper.class); + } + @Test void preservesEveryInboundOnSharedPalletAndIncludesOnlyThisDispatchsPickedPackages() { + var rows = mapper.list("101", 1); + assertThat(rows).hasSize(2); + assertThat(rows).extracting(row -> row.sourceInboundId()).containsExactly("11", "12"); + assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L); + assertThat(mapper.list("202", 1)).isEmpty(); + } + @Test void aSelectedPlanPalletCannotBorrowOtherInboundStockOnTheSamePhysicalPallet() { + jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)"); + jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)"); + assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index f7af714d3..825c3e312 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -94,6 +94,8 @@ class PlatformDispatchOutboundContractTest { Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class); Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, String.class, DeliveryRequests.Transfer.class); + Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class); + assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST); Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", AuthenticatedUser.class, String.class); Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound", @@ -105,7 +107,7 @@ class PlatformDispatchOutboundContractTest { assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); assertEquals(2, outboundDetail.getParameterCount()); assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL); - for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) { + for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) { assertTrue(Arrays.stream(method.getParameters()) .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java index ff88c80c8..9b187676e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -125,7 +125,7 @@ class MerchantDeliveryControllerTest { @Test void invalidCreatePayloadIsRejectedByBeanValidationContract() { DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null, - " ", " ", null, null, null, null, null, null, null, null, null); + " ", " ", null, null, null, null, null, null, null, null, null, null, null, null); try (var factory = Validation.buildDefaultValidatorFactory()) { assertTrue(factory.getValidator().validate(invalid).size() >= 6); } @@ -186,6 +186,6 @@ class MerchantDeliveryControllerTest { "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null, List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY", new BigDecimal("10.00"), null, null, null, null, null, null, null, null, - null, null, null, null, null, null))); + null, null, null, null, null, null)), null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index 6eadd68a1..fc320bcee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -26,6 +26,7 @@ import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*; @ExtendWith(MockitoExtension.class) class DeliveryDocumentServiceTest { @Mock DeliveryOrderMapper orders; + @Mock OutboundOrderMapper outboundOrders; @Mock DeliveryDocumentRevisionMapper revisions; @Mock DeliveryOperationMapper operations; @Mock DeliveryDocumentFileGateway files; @@ -37,6 +38,7 @@ class DeliveryDocumentServiceTest { @BeforeEach void setUp() { service = new DeliveryDocumentService(); ReflectionTestUtils.setField(service,"orders",orders); + ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders); ReflectionTestUtils.setField(service,"revisions",revisions); ReflectionTestUtils.setField(service,"operations",operations); ReflectionTestUtils.setField(service,"files",files); @@ -46,6 +48,7 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"customerVisits",customerVisits); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); + order.setOutboundNo("OUT-1"); } @Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() { when(orders.lockByNo("100","DL-1")).thenReturn(order); @@ -132,6 +135,8 @@ class DeliveryDocumentServiceTest { } @Test void savingReceiptAutomaticallyCompletesOnlyThisDeliveryAndItsBoundOrders() { order.setStatus("WAIT_DELIVERY"); + var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED"); + when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); LastlegOrder related = new LastlegOrder(); related.setId(20L); related.setCustomerId("100"); related.setDeliveryOrderNo("DL-1"); related.setSubOrderStatus("DELIVERY:WAIT_DELIVERY"); when(orders.lockByNo("100","DL-1")).thenReturn(order); @@ -151,6 +156,27 @@ class DeliveryDocumentServiceTest { assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); } + @Test void receiptCannotCompleteDeliveryBeforeItsActualOutbound() { + order.setStatus("WAIT_DELIVERY"); + var outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); + assertThatThrownBy(() -> service.save("100","DL-1","9",request(0,"too-early",List.of(new Binding(RECEIPT,List.of(13L)))))) + .hasMessageContaining("先完成实际出库"); + verify(orders, never()).updateById(any(DeliveryOrder.class)); + verifyNoInteractions(lastlegOrders, deliveryCompletedOutbox, customerVisits, operations); + assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY"); + assertThat(order.getVersion()).isZero(); + } + @Test void bolMayBePreparedBeforeOutboundWithoutCompletingDelivery() { + order.setStatus("WAIT_DELIVERY"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + service.save("100","DL-1","9",request(0,"prepare-bol",List.of(new Binding(BOL,List.of(11L))))); + assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY"); + verifyNoInteractions(outboundOrders, deliveryCompletedOutbox, customerVisits); + } @Test void merchantReadsOnlyCurrentReceiptFilesAndDownloadsWithAggregateOwnershipChecks() throws Exception { order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of( new Category(BOL,"UPLOADED",1,List.of(file(11))), 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 0bb621769..6b9e4d37d 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 @@ -53,6 +53,8 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Set; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -60,6 +62,7 @@ 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.Mockito.inOrder; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -111,11 +114,19 @@ class DeliveryTransferServiceTest { @Mock WarehouseTransferPalletMapper warehouseTransferPallets; private DeliveryTransferService service; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; + @Mock com.baiyue.lastleg.fleet.trip.FleetBookingFreight bookingFreight; + @Mock DeliveryFeeCoverageService feeCoverage; @BeforeEach void setUp() { service = new DeliveryTransferService(); - ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); + ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); + ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight); + ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage); + org.mockito.Mockito.lenient().when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("test-hash", true, List.of(), List.of())); + ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); @@ -141,6 +152,35 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets); } + @Test + void transferPreservesUnknownGrossWeightThroughPalletAndDeliverySnapshots() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + DispatchItem known = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + DispatchItem unknown = item(202L, 502L, "PLT-502", "ONT8", "Ontario", "IN-B", 8); + unknown.setGrossWeightKg(null); unknown.setVolumeWeightKg(new BigDecimal("999")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(known, unknown)); + service.platformTransfer("DSP-101", "U-1", transferRequest("unknown-gross", null)); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); + var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + var pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundOrders).insert(outbound.capture()); + verify(deliveryOrders).insert(delivery.capture()); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); + assertEquals(null, outbound.getValue().getGrossWeightKg()); + assertEquals(null, delivery.getValue().getTotalWeightKg()); + assertEquals(null, pallets.getAllValues().get(1).getGrossWeightKg()); + assertEquals(new BigDecimal("999"), pallets.getAllValues().get(1).getVolumeWeightKg()); + } + + @Test void transferCannotOverrideAnAssignmentOwnedByTheTrip() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "车辆司机由车次维护")) + .when(fleetBindings).requireMatchingAssignment(org.mockito.ArgumentMatchers.eq(101L), any(), any()); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", transferRequest("bound-transfer", null))); + org.mockito.Mockito.verifyNoInteractions(deliveryOrders, feeSources, dispatchItems); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + @Test void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); @@ -220,26 +260,78 @@ class DeliveryTransferServiceTest { assertEquals("LX-1", outbound.getValue().getLxCodes()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"}) + void changedFeesCannotReplayAnEarlierTransferAsSuccessful(String path) throws Exception { + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + var request = transferRequest("transfer-original", null); + service.platformTransfer("DSP-101", "U-1", request); + var created = ArgumentCaptor.forClass(OutboundOrder.class); + var delivered = ArgumentCaptor.forClass(DeliveryOrder.class); + verify(outboundOrders).insert(created.capture()); verify(deliveryOrders).insert(delivered.capture()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode changed = mapper.valueToTree(request); + ((com.fasterxml.jackson.databind.node.ObjectNode) changed.withArray("fees").get(0)).put("amount", new BigDecimal("20.00")); + if ("NEW_KEY".equals(path)) changed.put("idempotencyKey", "another-key"); + var retry = mapper.treeToValue(changed, DeliveryRequests.Transfer.class); + if ("NEW_KEY".equals(path)) when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(created.getValue()); + else if ("INSERT_RACE".equals(path)) { + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(null, created.getValue()); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race")); + } else when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(created.getValue()); + org.mockito.Mockito.lenient().when(deliveryOrders.lockByOutbound("TENANT-A", created.getValue().getId())).thenReturn(List.of(delivered.getValue())); + var failure = assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", retry)); + assertEquals(409, failure.getStatusCode().value()); + assertTrue(failure.getReason().contains("内容不一致")); + verify(feeSources).publishApproved(any(), any(), any()); + verify(deliveryOrders).insert(any(DeliveryOrder.class)); + } + @Test void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + var request = transferRequest(" transfer-101 ", null); + outbound.setRequestHash(service.transferRequestHash(request)); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", - transferRequest(" transfer-101 ", null)); + request); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); + org.mockito.Mockito.verifyNoInteractions(fleetBindings); verify(outboundOrders, never()).insert((OutboundOrder) any()); verify(outboundPallets, never()).insert((OutboundPalletItem) any()); verify(deliveryOrders, never()).insert((DeliveryOrder) any()); } + @Test + void identicalBusinessContentWithAnotherCommandKeyReturnsExistingDocumentsWithoutNewFees() throws Exception { + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + var original = transferRequest("first-key", null); + var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "first-key"); + outbound.setRequestHash(service.transferRequestHash(original)); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode content = mapper.valueToTree(original); + content.put("idempotencyKey", "another-key"); + var retry = mapper.treeToValue(content, DeliveryRequests.Transfer.class); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery(401L, outbound, "DEL-401"))); + var replay = service.platformTransfer("DSP-101", "U-1", retry); + assertTrue(replay.replayed()); assertEquals("OUT-301", replay.outboundNo()); + org.mockito.Mockito.verifyNoInteractions(feeSources, fleetBindings); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + } + @Test void idempotencyKeyCannotBeReusedForAnotherDispatch() { DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); @@ -258,6 +350,8 @@ class DeliveryTransferServiceTest { void concurrentDuplicateInsertReturnsWinningDocuments() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + var request = transferRequest("transfer-101", null); + winner.setRequestHash(service.transferRequestHash(request)); DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( @@ -267,7 +361,7 @@ class DeliveryTransferServiceTest { when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", - transferRequest("transfer-101", null)); + request); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); @@ -391,6 +485,8 @@ class DeliveryTransferServiceTest { void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key"); + var request = transferRequest(" merchant-key ", null); + outbound.setRequestHash(service.transferRequestHash(request)); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); @@ -398,7 +494,7 @@ class DeliveryTransferServiceTest { when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", - transferRequest(" merchant-key ", null)); + request); assertTrue(result.replayed()); assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); @@ -457,11 +553,12 @@ class DeliveryTransferServiceTest { OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING"); when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); - service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform")); - service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command")); + service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("cancelled by platform")); + service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("duplicate command")); assertEquals("CANCELLED", outbound.getStatus()); assertEquals("CANCELLED", item.getStatus()); @@ -473,10 +570,11 @@ class DeliveryTransferServiceTest { void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key"); outbound.setStatus("PICKED"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late"))); + service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("too late"))); assertEquals(409, exception.getStatusCode().value()); assertEquals("PICKED", outbound.getStatus()); @@ -484,6 +582,66 @@ class DeliveryTransferServiceTest { verify(outboundPallets, never()).findByOutbound(any(), any()); } + @Test @SuppressWarnings({"rawtypes", "unchecked"}) + void cancellingDeliveryRequestsOnlyUnperformedFreightReceivablesAndPreservesOtherCosts() { + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(new org.apache.ibatis.builder.MapperBuilderAssistant( + new com.baomidou.mybatisplus.core.MybatisConfiguration(), "cancellation-test"), LastlegOrder.class); + var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key"); + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PENDING_DEPARTURE"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + var pallet = pallet(501L, "P-1", "ONT8", "Ontario", "IN-1", 12); pallet.setStatus("WAIT_PICK"); pallet.setPickedPackageCount(0); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet)); + var delivery = new DeliveryOrder(); delivery.setId(401L); delivery.setDeliveryNo("DEL-1"); delivery.setStatus("WAIT_DELIVERY"); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); + var freight = cancellationFee(701, "DELIVERY_FEE", "RECEIVABLE"); + when(feeSources.listForBusiness("TENANT-A", "DELIVERY_ORDER", 401L)).thenReturn(List.of(freight, + cancellationFee(702, "APPOINTMENT_FEE", "RECEIVABLE"), cancellationFee(703, "DELIVERY_FEE", "PAYABLE"))); + var order = new LastlegOrder(); order.setId(901L); order.setCustomerId("TENANT-A"); order.setOutboundOrderNo("OUT-301"); order.setCurrentStatus("WAIT_OUTBOUND"); order.setVersion(3); + when(tailOrders.selectList(any())).thenReturn(List.of(order)); when(tailOrders.lockById("TENANT-A", 901L)).thenReturn(order); + var previous = new LastlegOrderStatusHistory(); previous.setFromStatus("INBOUND_COMPLETED"); + when(histories.selectOne(any())).thenReturn(previous); + when(tailOrders.update(org.mockito.ArgumentMatchers.isNull(), any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(1); + String longReason = "取消运输".repeat(200); + service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel(longReason)); + var request = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest.class); + verify(feeSources).voidSource(org.mockito.ArgumentMatchers.eq(701L), org.mockito.ArgumentMatchers.eq("OP-1"), request.capture()); + assertEquals(1, request.getValue().expectedVersion()); + assertEquals("CANCELLED", delivery.getStatus()); assertEquals("CANCELLED", outbound.getStatus()); + var restored = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper.class); + verify(tailOrders).update(org.mockito.ArgumentMatchers.isNull(), restored.capture()); + assertTrue(restored.getValue().getSqlSet().contains("outbound_order_no=")); + assertTrue(restored.getValue().getSqlSet().contains("delivery_order_no=")); + assertTrue(restored.getValue().getParamNameValuePairs().containsValue("INBOUND_COMPLETED")); + assertTrue(restored.getValue().getParamNameValuePairs().containsValue(null)); + var operation = ArgumentCaptor.forClass(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class); + verify(deliveryOperations, org.mockito.Mockito.times(3)).insert(operation.capture()); + assertTrue(operation.getAllValues().stream().allMatch(row -> row.getResult().length() <= 500)); + assertEquals(longReason, operation.getAllValues().stream().filter(row -> "CANCEL_OUTBOUND_REASON".equals(row.getOperationNode())) + .map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining())); + verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any()); + verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any()); + var ordered = org.mockito.Mockito.inOrder(outboundOrders, dispatchOrders, fleetBindings); + ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(dispatchOrders).lockById("TENANT-A", 101L); + ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L); + service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试")); + verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any()); + } + @Test void dispatchPickingCannotBeCancelledWhileOutboundSnapshotStillLooksUnexecuted() { + var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key"); + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PICKING"); dispatch.setPickedPackageCount(1); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + assertThrows(ResponseStatusException.class, () -> service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("取消"))); + org.mockito.Mockito.verifyNoInteractions(feeSources, outboundPallets, deliveryOrders); + } + private static com.baiyue.lastleg.financefee.entity.OverseasFeeSource cancellationFee(long id, String type, String direction) { + var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); fee.setId(id); fee.setSourceFeeId(id); fee.setSourceVersion(1); + fee.setFeeItemCode(type); fee.setDirection(direction); fee.setSourceStatus("ACTIVE"); fee.setReviewStatus("APPROVED"); return fee; + } + @Test void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() { for (long i = 1; i <= 2; i++) { @@ -967,6 +1125,96 @@ class DeliveryTransferServiceTest { .getStatusCode().value()); } + @Test void externalFreightPublishesOnlyTheConfirmedQuoteOnItsOwnDestination() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12), + item(202L, 502L, "PLT-502", "LAX9", "LA", "WT-2", 8))); + var q1 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", new BigDecimal("60.00"), new BigDecimal("2"), "托位", new BigDecimal("30.00")); + var q2 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-2", 4, "WT-2", "IN-2", "客户甲", "FBA:LAX9", "CNY", BigDecimal.ZERO, BigDecimal.ONE, "单", BigDecimal.ZERO); + when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("external-hash", false, List.of(q1, q2), List.of("IN-1", "IN-2"))); + var request = externalRequest("external-hash", List.of()); + service.platformTransfer("DSP-101", "OP-1", request); + var deliveries = ArgumentCaptor.forClass(DeliveryOrder.class); + verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(deliveries.capture()); + var fee = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); + verify(feeSources).publishApproved(org.mockito.ArgumentMatchers.eq("OP-1"), org.mockito.ArgumentMatchers.eq("FLEET:FB-1:DSP-101:FREIGHT"), fee.capture()); + verify(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1", "IN-2")); + assertEquals(new BigDecimal("60.00"), fee.getValue().amount()); + assertEquals("IN-1", fee.getValue().inboundCode()); + assertEquals(deliveries.getAllValues().stream().filter(d -> "ONT8".equals(d.getDestinationWarehouseCode())).findFirst().orElseThrow().getId(), fee.getValue().businessId()); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).insert(outbound.capture()); + when(outboundOrders.findByIdempotency("TENANT-A", "external-1")).thenReturn(outbound.getValue()); + when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(deliveries.getAllValues()); + assertTrue(service.platformTransfer("DSP-101", "OP-OTHER", request).replayed()); + org.mockito.Mockito.verifyNoMoreInteractions(feeSources); + } + + @Test void externalFreightRejectsStalePreviewAndDuplicateDeliveryChargeBeforeWriting() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12))); + var q = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", BigDecimal.TEN, BigDecimal.ONE, "单", BigDecimal.TEN); + when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("current-hash", false, List.of(q), List.of("IN-1"))); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("old-hash", List.of()))); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("current-hash", List.of(receivableDeliveryFee())))); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + org.mockito.Mockito.verifyNoInteractions(feeSources, deliveryOrders); + } + @Test void rebookingAfterCancellationCannotChargeAgainBeforeTheOriginalReceivableIsVoided() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "WT-1", 12))); + when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("cancelled-source", true, List.of(), List.of("IN-1"))); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "原运费待财务作废")) + .when(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1")); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-NEW", transferRequest("new-trip", null))); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + verify(feeSources, never()).publishApproved(any(), any(), any()); + } + @Test void confirmedOriginalFeeCoverageSavesEvidenceWithoutPublishingAFabricatedNewFee() throws Exception { + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + var input = json.valueToTree(externalRequest(null, List.of())); + ((com.fasterxml.jackson.databind.node.ObjectNode) input).set("feeCoverage", json.valueToTree(List.of( + new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE"))))); + ((com.fasterxml.jackson.databind.node.ObjectNode) input).put("feeCoverageCargoHash", "cargo-hash"); + var request = json.treeToValue(input, DeliveryRequests.Transfer.class); + var evidence = new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence("cargo-hash", List.of(), List.of(), "OP-1", java.time.Instant.parse("2026-10-03T00:00:00Z")); + when(feeCoverage.confirm(org.mockito.ArgumentMatchers.eq("DSP-101"), org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq("OP-1"), any())) + .thenReturn(new DeliveryFeeCoverageService.Decision(Set.of("DELIVERY_FEE"), evidence, Set.of())); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "IN-A", 12))); + service.platformTransfer("DSP-101", "OP-1", request); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(outboundOrders).insert(outbound.capture()); + assertEquals("cargo-hash", json.readTree(outbound.getValue().getFeeCoverageJson()).get("cargoHash").asText()); + org.mockito.Mockito.verifyNoInteractions(feeSources); + var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); verify(deliveryOrders).insert(delivery.capture()); + when(outboundOrders.findByIdempotency("TENANT-A", request.idempotencyKey())).thenReturn(outbound.getValue()); + when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(List.of(delivery.getValue())); + assertTrue(service.platformTransfer("DSP-101", "OP-1", request).replayed()); + verify(feeCoverage, org.mockito.Mockito.times(1)).confirm(any(), any(), any(), any()); + } + @Test void emptyCoverageRepresentationsDoNotCreateFalseIdempotencyConflicts() { + var original = transferRequest("key", null); + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode body = json.valueToTree(original); + body.putArray("feeCoverage"); body.put("feeCoverageCargoHash", "unused-hash"); + var empty = json.convertValue(body, DeliveryRequests.Transfer.class); + assertEquals(service.transferRequestHash(original), service.transferRequestHash(empty)); + } + @Test void merchantCannotDeclareOriginalFeeCoverageForItself() { + var request = new com.fasterxml.jackson.databind.ObjectMapper().convertValue(Map.of("feeCoverage", List.of( + new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))), DeliveryRequests.Transfer.class); + var error = assertThrows(ResponseStatusException.class, () -> service.merchantTransfer("TENANT-A", "DSP-101", "OP-1", request)); + assertEquals(403, error.getStatusCode().value()); org.mockito.Mockito.verifyNoInteractions(dispatchOrders, feeCoverage, feeSources); + } + private static DeliveryRequests.Transfer externalRequest(String hash, List fees) { + return new DeliveryRequests.Transfer("external-1", LocalDateTime.of(2026,10,3,12,0), "TRUCK_PALLET", null, null, + "WAREHOUSE_PICKUP", "PALLET", null, null, null, null, null, null, null, null, fees, hash, null, null); + } + private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { DispatchOrder value = new DispatchOrder(); value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); @@ -977,7 +1225,7 @@ class DeliveryTransferServiceTest { private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) { return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark, - List.of(receivableDeliveryFee())); + List.of(receivableDeliveryFee()), null, null, null); } private static DeliveryRequests.FeeItem receivableDeliveryFee() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index a3c404eb0..57c9f8b26 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { @@ -131,7 +132,7 @@ class DispatchCandidateMapperContractTest { } @Test - void candidateGrossWeightIsAllocatedByPalletAndFallsBackToVolumeWeight() throws Exception { + void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception { for (Method method : List.of( DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), @@ -142,7 +143,7 @@ class DispatchCandidateMapperContractTest { assertTrue(sql.contains("ll_inbound_order inbound")); assertTrue(sql.contains("a.allocated_volume_cbm")); assertTrue(sql.contains("a2.line_id=a.line_id")); - assertTrue(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg")); + assertFalse(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java new file mode 100644 index 000000000..a25e8bfcd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.dispatch; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.math.BigDecimal; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DispatchPhysicalWeightTest { + @Test void billingVolumeWeightCannotFillMissingPhysicalWeight() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))"); + jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)"); + int tested = 0; + for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) { + var annotation = method.getAnnotation(Select.class); + if (annotation == null) continue; + String sql = String.join(" ", annotation.value()).replace(">", ">").replace("<", "<"); + int from = sql.indexOf("AS volumeWeightKg,"); + int to = sql.indexOf(" AS grossWeightKg,"); + if (from < 0 || to < 0) continue; + String projection = sql.substring(from + "AS volumeWeightKg,".length(), to); + String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip " + + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l " + + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a " + + "CROSS JOIN (SELECT 999 AS volume_weight_kg) o"; + assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull(); + if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet. + jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)"); + assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20"); + String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10"); + assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull(); + jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)"); + assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40"); + jdbc.update("DELETE FROM ll_order_detail"); + tested++; + } + assertThat(tested).isEqualTo(2); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 57ec44595..15a472cba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -32,7 +32,8 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码")); - assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重")); + assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重")); + assertTrue(yaml.contains("禁止使用体积重替代")); String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), yaml.indexOf(" SmartDispatchDraftPallet:")); String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index c7945e1e8..984c27160 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -40,6 +40,8 @@ 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.anyLong; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -57,12 +59,14 @@ class DispatchPdaWorkflowServiceTest { @Mock InventoryPositionMapper positions; @Mock InventoryCommandService inventory; @Mock DispatchPickOrderAttributionService attribution; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; private DispatchPdaWorkflowService service; private ObjectMapper objectMapper; @BeforeEach void setUp() { service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); objectMapper = new ObjectMapper(); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); @@ -167,6 +171,7 @@ class DispatchPdaWorkflowServiceTest { DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); assertEquals(2, result.packages()); + verify(fleetBindings, never()).requirePickingApproved(anyLong()); verify(inventory, never()).allocate(any(), any(), any()); verify(picks, never()).insert((DispatchPdaPick) any()); verify(items, never()).bindReceivedPallets(any(), any()); @@ -231,12 +236,36 @@ class DispatchPdaWorkflowServiceTest { when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null))); + () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); assertEquals(409, exception.getStatusCode().value()); verify(outboundOrders, never()).updateById((OutboundOrder) any()); } + @Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING")); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货")) + .when(fleetBindings).requirePickingApproved(1L); + assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null))); + assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete"))); + org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution); + verify(orders, never()).updateById((DispatchOrder) any()); + } + + @Test void boundDispatchCannotDepartBeforeTripCoordination() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车")) + .when(fleetBindings).requireCoordinatedDeparture(1L); + assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); + org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders); + verify(orders, never()).updateById((DispatchOrder) any()); + } + @Test void successfulDeparturePersistsBusinessDepartureTime() { DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); @@ -262,7 +291,7 @@ class DispatchPdaWorkflowServiceTest { when(palletLocks.selectList(any())).thenReturn(List.of()); DispatchPdaResponses.Departed result = service.depart( - "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)); + "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()); assertEquals("DEPARTED", result.status()); assertNotNull(order.getDepartAt()); @@ -284,7 +313,7 @@ class DispatchPdaWorkflowServiceTest { when(orders.lockById("100", 1L)).thenReturn(order); when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted)); - DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request); + DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now()); assertEquals("DEPARTED", result.status()); assertTrue(result.replayed()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index b0c28e5e6..63e740ef4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -43,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -59,11 +60,13 @@ class DispatchServiceTest { @Mock DispatchPdaTaskMapper pdaTasks; @Mock LastlegOrderMapper tailOrders; @Mock OutboundOrderMapper outboundOrders; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; private DispatchService service; @BeforeEach void setUp() { service = new DispatchService(); + ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "bolLines", bolLines); @@ -383,9 +386,22 @@ class DispatchServiceTest { assertEquals("ONT8,ABE4", result.route()); assertEquals("ONT8,ABE4", dispatch.getRoute()); verify(orders).updateById(dispatch); + verify(fleetBindings).requireUnbound(1L); verify(items).findByDispatchOrder("100", 1L); } + @Test void boundDispatchCannotEditRemoveOrCancelIndependently() { + DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联")) + .when(fleetBindings).requireUnbound(1L); + assertThrows(ResponseStatusException.class, () -> service.update("PC1", null)); + assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L)); + assertThrows(ResponseStatusException.class, () -> service.cancel("PC1")); + verify(orders, never()).updateById((DispatchOrder) any()); + org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders); + } + @Test void platformEditRejectsAnExecutedDispatchWithoutWriting() { DispatchOrder dispatch = new DispatchOrder(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java new file mode 100644 index 000000000..c15edc857 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.financefee; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; + +class OverseasFeeCancellationContractTest { + @Test void changedContractsHaveNoDuplicateKeysAndSeparateTheRequestFromFinancialConfirmation() throws Exception { + var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); + var yaml = new Yaml(new SafeConstructor(options)); + for (var name : new String[]{"overseas-business-v1.yaml", "overseas-finance-fees-v1.yaml"}) { + String source = Files.readString(Path.of("src/main/resources/openapi", name)); + Map parsed = yaml.load(source); + assertThat(parsed).containsKeys("paths", "components"); + assertThat(source).contains("PENDING_FINANCE", "FINANCE_ATTENTION", "CONFIRMED"); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java new file mode 100644 index 000000000..75e059060 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.financefee; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class OverseasFeeCancellationPersistenceTest { + JdbcTemplate jdbc; + LastlegEventOutboxMapper events; + OverseasFeeSourceMapper fees; + OverseasFeeCancellation cancellation; + OverseasFeeOutboxRepository repository; + EventDeliveryJournal journal; + OverseasFeeSource source; + LastlegEventOutbox request; + final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 1, 0); + @BeforeEach void setup() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')"); + var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); + var session = new SqlSessionTemplate(factory.getObject()); + events = session.getMapper(LastlegEventOutboxMapper.class); fees = session.getMapper(OverseasFeeSourceMapper.class); + var json = new ObjectMapper().findAndRegisterModules(); + cancellation = new OverseasFeeCancellation(events, fees, json); + repository = new OverseasFeeOutboxRepository(events, cancellation); + journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + source = fees.lockCurrent(701); + var producer = new OverseasFeeOutboxService(); + ReflectionTestUtils.setField(producer, "mapper", events); ReflectionTestUtils.setField(producer, "objectMapper", json); + producer.publish(source, "PUBLISHED"); + var copy = new OverseasFeeSource(); org.springframework.beans.BeanUtils.copyProperties(source, copy); copy.setSourceStatus("VOIDED"); + producer.publish(copy, "VOIDED"); + request = events.findByEventKey(OverseasFeeCancellation.eventKey(source, "VOIDED")); + } + @Test void waitsForOriginalPublicationAndDoesNotCallPendingARequestAVoid() { + assertThat(cancellation.prerequisitePublished(request)).isFalse(); + assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("PENDING_FINANCE"); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED")); + assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION"); + assertThat(cancellation.prerequisitePublished(request)).isFalse(); + publishedOriginal(); assertThat(cancellation.prerequisitePublished(request)).isTrue(); + assertThat(status()).isEqualTo("ACTIVE"); + } + @Test void waitingCancellationsCannotStarveLaterReadyPublications() { + for (int i = 0; i < 60; i++) jdbc.update("INSERT INTO ll_event_outbox(id,event_type,event_key,status,created_at) VALUES(?,? ,?,'PENDING',?)", + 1000 + i, request.getEventType(), "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_DELIVERY_FEE:" + (800 + i) + ":1:VOIDED", now.minusDays(1)); + var ready = repository.pending(50, now.plusDays(1), now.minusSeconds(60)); + assertThat(ready).hasSize(1); + assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED")); + } + @Test void journalFailureRollsBackBothLocalVoidAndOutboxCompletionThenSameAckCanCommit() { + publishedOriginal(); claim(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> complete(Outcome.APPLIED)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("ACTIVE"); + assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(complete(Outcome.DUPLICATE)).isTrue(); + assertThat(status()).isEqualTo("VOIDED"); + assertThat(cancellation.describe(List.of(fees.lockCurrent(701))).get(0).getVoidStatus()).isEqualTo("CONFIRMED"); + assertThat(complete(Outcome.DUPLICATE)).isFalse(); + } + @Test void rejectedOrUnknownConsumerOutcomeKeepsTheFinancialSourceActive() { + publishedOriginal(); claim(); + assertThat(journal.complete("worker", Outcome.DEAD, now, "PERMANENT_FAILURE", null, + () -> repository.dead(request.getId(), "worker", 1, "来源已进入有效账单"))).isTrue(); + assertThat(status()).isEqualTo("ACTIVE"); + assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION"); + assertThat(repository.published(request.getId(), "stale-worker", now)).isZero(); + assertThat(status()).isEqualTo("ACTIVE"); + } + @Test void wrongSourceVersionCannotAcknowledgeOrCompleteTheEvent() { + publishedOriginal(); claim(); + jdbc.update("UPDATE ll_finance_fee_source SET source_version=2 WHERE id=10"); + assertThatThrownBy(() -> complete(Outcome.APPLIED)).hasMessageContaining("当前业务版本不一致"); + assertThat(status()).isEqualTo("ACTIVE"); + assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING"); + } + @Test void aNewShipmentWaitsForThePreviousActualFreightToBeVoidedAndUsesTheLatestVersion() { + var sourceService = new com.baiyue.lastleg.financefee.service.OverseasFeeSourceService(fees, + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper.class), + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeBusinessGuard.class), + org.mockito.Mockito.mock(OverseasFeeOutboxService.class), + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter.class), + new ObjectMapper().findAndRegisterModules(), cancellation); + jdbc.update("UPDATE ll_finance_fee_source SET business_type='DELIVERY_ORDER',direction='RECEIVABLE',fee_item_code='DELIVERY_FEE',inbound_code='IN-1',review_status='APPROVED' WHERE id=10"); + assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废"); + assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10002", List.of("IN-1"))).doesNotThrowAnyException(); + assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-OTHER"))).doesNotThrowAnyException(); + jdbc.update("UPDATE ll_finance_fee_source SET source_status='SUPERSEDED' WHERE id=10"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status,business_type,direction,fee_item_code,inbound_code,review_status) VALUES(11,701,2,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE','DELIVERY_ORDER','RECEIVABLE','DELIVERY_FEE','IN-1','PENDING')"); + assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废"); + jdbc.update("UPDATE ll_finance_fee_source SET source_status='VOIDED' WHERE id=11"); + assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).doesNotThrowAnyException(); + } + void publishedOriginal() { jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED")); } + void claim() { assertThat(journal.begin("worker", "lastleg-service", request.getEventType(), String.valueOf(request.getId()), null, "finance", now, + () -> repository.claim(request.getId(), "worker", now))).isTrue(); } + boolean complete(Outcome outcome) { return journal.complete("worker", outcome, now, null, null, () -> repository.published(request.getId(), "worker", now)); } + String status() { return jdbc.queryForObject("SELECT source_status FROM ll_finance_fee_source WHERE id=10", String.class); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java index af63f701c..ab4f8abec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java @@ -19,6 +19,7 @@ import static org.mockito.Mockito.*; class OverseasFeeOutboxRetryTest { @Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() { var repository = mock(OverseasFeeOutboxRepository.class); + when(repository.prerequisitePublished(any())).thenReturn(true); var client = mock(DirectEventClient.class); var relay = new OverseasFeeOutboxRelay(); var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java index 59c3f2e4c..4492db620 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ class OverseasContainerFeeSourceServiceTest { private final OverseasFeeSourceService service = new OverseasFeeSourceService( mapper, commands, guard, outbox, new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)), - new ObjectMapper().findAndRegisterModules()); + new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class)); @Test void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java new file mode 100644 index 000000000..20ba70019 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OverseasFeeCancellationRequestTest { + final OverseasFeeSourceMapper fees = mock(OverseasFeeSourceMapper.class); + final OverseasFeeCommandMapper commands = mock(OverseasFeeCommandMapper.class); + final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); + final OverseasFeeCancellation cancellation = mock(OverseasFeeCancellation.class); + final OverseasFeeSourceService service = new OverseasFeeSourceService(fees, commands, + mock(OverseasFeeBusinessGuard.class), outbox, mock(OverseasFeeCurrencyConverter.class), + new ObjectMapper().findAndRegisterModules(), cancellation); + @Test void requestPreservesActiveSourceAndPublishesASeparateVoidSnapshot() { + var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee); + var result = service.voidSource(701, "operator", new OverseasFeeSourceDtos.VoidRequest(1, "取消未履行运输", "cancel-1")); + assertThat(result.getSourceStatus()).isEqualTo("ACTIVE"); + assertThat(result.getVoidStatus()).isEqualTo("PENDING_FINANCE"); + var emitted = ArgumentCaptor.forClass(OverseasFeeSource.class); + verify(outbox).publish(emitted.capture(), eq("VOIDED")); + assertThat(emitted.getValue()).isNotSameAs(result); + assertThat(emitted.getValue().getSourceStatus()).isEqualTo("VOIDED"); + verify(fees).updateById(result); verify(commands).insert(any(com.baiyue.lastleg.financefee.entity.OverseasFeeCommand.class)); + } + @Test void aPendingVoidCannotBeReplacedByAnAdjustment() { + var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee); when(cancellation.requested(fee)).thenReturn(true); + assertThatThrownBy(() -> service.adjust(701, "operator", new OverseasFeeSourceDtos.AdjustRequest(1, null, "adjust-1"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("已申请作废"); + verifyNoInteractions(outbox); verify(fees, never()).updateById(any(OverseasFeeSource.class)); + } + static OverseasFeeSource approved() { + var fee = new OverseasFeeSource(); fee.setId(10L); fee.setSourceFeeId(701L); fee.setSourceVersion(1); + fee.setCustomerId("10001"); fee.setSourceType("OVERSEAS_DELIVERY_FEE"); fee.setSourceStatus("ACTIVE"); + fee.setReviewStatus("APPROVED"); fee.setPublicationStatus("PUBLISHED"); return fee; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java new file mode 100644 index 000000000..8546f95e2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.fleet.customer; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class FleetCustomerDirectoryTest { + @Test void exactActiveCustomerAndCurrentEligibilityAreRequired() { + var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token"); + when(client.customers("internal-test-token",1,30,null,"ACTIVE",9L)).thenReturn(ApiResponse.success(new PageResult<>(List.of(new FleetCustomerClient.Customer(9L,"00009","客户甲","ACTIVE",null,null)),1,30,1,1))); + when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(true,"ACTIVE",null))); + assertThat(directory.required(9).code()).isEqualTo("00009"); + when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(false,"PENDING","待审核"))); + assertThatThrownBy(()->directory.required(9)).hasMessageContaining("下单资格"); + } + @Test void failedDirectoryDoesNotInventCustomerOrPermission() { + var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token"); + when(client.customers(anyString(),anyLong(),anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("network")); + assertThatThrownBy(()->directory.required(9)).hasMessageContaining("暂不可用"); + verify(client,never()).eligibility(anyString(),anyLong()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java new file mode 100644 index 000000000..6d48f0448 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.domain; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import static com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.*; +import static org.assertj.core.api.Assertions.assertThat; + +class DriverQualificationPolicyTest { + private final Instant now = Instant.parse("2026-10-02T16:00:00Z"); + private final ZoneId zone = ZoneId.of("America/Los_Angeles"); + + private Driver driver(LocalDate expiry) { return new Driver(true, ReviewStatus.APPROVED, true, expiry, zone); } + + @Test void expiringWithinThirtyDaysAllowsTripEndingBeforeExpiry() { + var result = evaluate(driver(LocalDate.of(2026, 10, 5)), now, now.plusSeconds(3600)); + assertThat(result.canDispatch()).isTrue(); + assertThat(result.expiringSoon()).isTrue(); + } + + @Test void thirtyDayWindowIncludesBoundaryButNotThirtyOneDays() { + assertThat(evaluate(driver(LocalDate.of(2026, 11, 1)), now, now.plusSeconds(60)).expiringSoon()).isTrue(); + assertThat(evaluate(driver(LocalDate.of(2026, 11, 2)), now, now.plusSeconds(60)).expiringSoon()).isFalse(); + } + + @Test void usesLicenceZoneAndIncludesEntireExpiryDate() { + var d = driver(LocalDate.of(2026, 10, 2)); + assertThat(evaluate(d, now, Instant.parse("2026-10-03T06:59:59Z")).canDispatch()).isTrue(); + assertThat(evaluate(d, now, Instant.parse("2026-10-03T07:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP); + } + + @Test void daylightSavingBoundaryUsesCalendarDayInsteadOfFixedHours() { + var d = driver(LocalDate.of(2026, 11, 1)); + Instant start = Instant.parse("2026-11-01T07:00:00Z"); + assertThat(evaluate(d, start, Instant.parse("2026-11-02T07:59:59Z")).canDispatch()).isTrue(); + assertThat(evaluate(d, start, Instant.parse("2026-11-02T08:00:00Z")).canDispatch()).isFalse(); + } + + @Test void rejectsExpiredAndUnknownDatesOrEndTimes() { + assertThat(evaluate(driver(LocalDate.of(2026, 10, 1)), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.EXPIRED); + assertThat(evaluate(driver(null), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_EXPIRY); + assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, null).reason()).isEqualTo(Reason.MISSING_TRIP_END); + assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, now).reason()).isEqualTo(Reason.INVALID_TRIP_END); + } + + @Test void reviewAndDocumentGatesCannotBeBypassedWithValidDate() { + LocalDate expiry = LocalDate.of(2027, 1, 1); + assertThat(evaluate(new Driver(false, ReviewStatus.APPROVED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.INACTIVE); + assertThat(evaluate(new Driver(true, ReviewStatus.APPROVED, false, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_LICENSE); + assertThat(evaluate(new Driver(true, ReviewStatus.PENDING, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.PENDING_REVIEW); + assertThat(evaluate(new Driver(true, ReviewStatus.REJECTED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.REJECTED); + } + + @Test void delayedTripIsReevaluatedRatherThanReusingAnEarlierApproval() { + var d = driver(LocalDate.of(2026, 10, 5)); + assertThat(evaluate(d, now, now.plusSeconds(3600)).canDispatch()).isTrue(); + assertThat(evaluate(d, now, Instant.parse("2026-10-06T09:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java new file mode 100644 index 000000000..12aeb0f9b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.fleet.domain; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.*; +import static org.assertj.core.api.Assertions.*; + +class TripCapacityPolicyTest { + private Load load(int pallets, String kg, String cbm) { return new Load(pallets, new BigDecimal(kg), new BigDecimal(cbm)); } + private final Load capacity = new Load(30, new BigDecimal("18000"), new BigDecimal("70")); + + @Test void combinesOwnedAndExternalCargoWithinAllCapacityLimits() { + var result = calculate(2, capacity, List.of(new Booking("OWN-1", 0, 1, load(20, "10000", "40")), + new Booking("EXT-1", 0, 1, load(10, "8000", "30")))); + assertThat(result.get(0).remaining()).isEqualTo(load(0, "0", "0")); + } + + @Test void releasesUnloadedCargoBeforeNextPickupAtSameStop() { + var result = calculate(3, capacity, List.of(new Booking("A", 0, 1, load(30, "18000", "70")), + new Booking("B", 1, 2, load(30, "18000", "70")))); + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(segment -> assertThat(segment.occupied().palletSpaces()).isEqualTo(30)); + } + + @Test void rejectsOverlappingSegmentsEvenWhenSomeStopsHaveSpareCapacity() { + assertThatThrownBy(() -> calculate(4, capacity, List.of(new Booking("A", 0, 2, load(20, "8000", "20")), + new Booking("B", 1, 3, load(15, "5000", "20"))))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("第2段"); + } + + @Test void rejectsOverweightOrOversizeEvenWithFreePalletSpaces() { + assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "18001", "1"))))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "1", "70.01"))))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test void rejectsDuplicateBookingAndStopsOutsideRoute() { + var booking = new Booking("A", 0, 1, load(1, "10", "1")); + assertThatThrownBy(() -> calculate(2, capacity, List.of(booking, booking))).hasMessageContaining("重复"); + assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("B", 0, 2, load(1, "10", "1"))))) + .hasMessageContaining("不属于"); + } + + @Test void refusesUnknownCapacityAndMissingCargoMeasurements() { + assertThatThrownBy(() -> calculate(2, load(30, "0", "70"), List.of())).hasMessageContaining("大于零"); + assertThatThrownBy(() -> new Booking("A", 0, 1, load(1, "0", "1"))).hasMessageContaining("有效"); + assertThatThrownBy(() -> new Booking("A", 1, 1, load(1, "10", "1"))).hasMessageContaining("之后"); + assertThatThrownBy(() -> load(1, "-1", "1")).hasMessageContaining("非负"); + } + + @Test void emptyTripRetainsCapacityOnEachSegment() { + assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java new file mode 100644 index 000000000..88ce1813c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.fleet.domain; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy.*; +import static org.assertj.core.api.Assertions.*; + +class TripCostAllocationPolicyTest { + @Test void allocatesTwentyOwnedAndTenExternalPalletsWithoutCreatingRevenue() { + assertThat(allocate(new BigDecimal("600.00"), "USD", List.of(new Share("OWN-1", 20), new Share("EXT-1", 10)))) + .containsExactly(new Allocation("EXT-1", new BigDecimal("200.00")), new Allocation("OWN-1", new BigDecimal("400.00"))); + } + + @Test void roundingConservesTotalAndIsIndependentOfRequestOrder() { + var first = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("C", 1), new Share("A", 1), new Share("B", 1))); + var second = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("B", 1), new Share("C", 1), new Share("A", 1))); + assertThat(first).isEqualTo(second); + assertThat(first).containsExactly(new Allocation("A", new BigDecimal("3.34")), + new Allocation("B", new BigDecimal("3.33")), new Allocation("C", new BigDecimal("3.33"))); + assertThat(first.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("10.00"); + } + + @Test void singleCentAndZeroCostRemainExact() { + var shares = List.of(new Share("A", 1), new Share("B", 2)); + assertThat(allocate(new BigDecimal("0.01"), "CNY", shares)) + .containsExactly(new Allocation("A", new BigDecimal("0.00")), new Allocation("B", new BigDecimal("0.01"))); + assertThat(allocate(BigDecimal.ZERO, "CNY", shares)).allSatisfy(a -> assertThat(a.amount()).isZero()); + } + + @Test void rejectsUnmeasuredDuplicatedAndInvalidCostInputs() { + var shares = List.of(new Share("A", 1)); + assertThatThrownBy(() -> allocate(new BigDecimal("1.001"), "USD", shares)).hasMessageContaining("两位"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, "EUR", shares)).hasMessageContaining("币种"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, null, shares)).hasMessageContaining("币种"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE.negate(), "USD", shares)).hasMessageContaining("负数"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of())).hasMessageContaining("没有"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of(new Share("A", 1), new Share("A", 2)))).hasMessageContaining("重复"); + } + + @Test void handlesLargeValuesWithoutIntegerOverflow() { + var amount = new BigDecimal("99999999999999.99"); + var result = allocate(amount, "USD", List.of(new Share("A", Long.MAX_VALUE), new Share("B", Long.MAX_VALUE))); + assertThat(result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(amount); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java new file mode 100644 index 000000000..c41414ae7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; + +class DriverApiContractTest { + @Test void everyControllerOperationHasOpenApiAndRegisteredPermission() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/fleet-drivers-v1.yaml")); + Map document = new Yaml().load(yaml); + Map paths = (Map) document.get("paths"); + String seeds = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + String base = PlatformDriverController.class.getAnnotation(RequestMapping.class).value()[0]; + int operationCount = 0; + for (var method : PlatformDriverController.class.getDeclaredMethods()) { + String verb = null; + String[] relative = null; + if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; relative = method.getAnnotation(GetMapping.class).value(); } + if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; relative = method.getAnnotation(PostMapping.class).value(); } + if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; relative = method.getAnnotation(PutMapping.class).value(); } + if (method.isAnnotationPresent(PatchMapping.class)) { verb = "patch"; relative = method.getAnnotation(PatchMapping.class).value(); } + if (verb == null) continue; + operationCount++; + String path = base + (relative.length == 0 ? "" : relative[0]); + assertThat(paths.containsKey(path)).as(path).isTrue(); + Map operation = (Map) ((Map) paths.get(path)).get(verb); + assertThat(operation).as(verb + " " + path).isNotNull(); + String expression = method.getAnnotation(PreAuthorize.class).value(); + String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\'')); + assertThat(operation.get("description").toString()).contains(permission); + assertThat(seeds).contains("'" + permission + "'"); + assertThat(unified).contains("'" + permission + "'"); + } + assertThat(operationCount).isEqualTo(12); + verifyRefs(document, document); + } + + @Test void driverTablesMatchDeploymentBaselineAndKeepUniqueHistoryVersions() throws Exception { + String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) { + var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;"); + var a = pattern.matcher(module); var b = pattern.matcher(deployed); + assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue(); + assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n")); + } + assertThat(deployed).contains("UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version)") + .contains("FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)"); + } + + private static void verifyRefs(Object node, Map document) { + if (node instanceof Map map) { + if (map.get("$ref") instanceof String ref) { + assertThat(ref).startsWith("#/"); + Object value = document; + for (String key : ref.substring(2).split("/")) value = ((Map) value).get(key); + assertThat(value).as(ref).isNotNull(); + } + map.values().forEach(value -> verifyRefs(value, document)); + } else if (node instanceof Iterable values) values.forEach(value -> verifyRefs(value, document)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java new file mode 100644 index 000000000..57dcffe8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DriverAuthorizationTest { + @AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); } + + @Test void listPermissionReadsDetailsButCannotModifyOrApprove() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformDriverController.class); + var service = context.getBean(DriverService.class); + authenticate("platform:overseas-master-data:driver:list"); + controller.detail(71, null); + verify(service).detail(71, null); + assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.update(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.review(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.status(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.submit(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.upload(null, 71, 0, null)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + } + + @Test void unrelatedPermissionCannotReadSensitiveDriverDetailsOrAttachments() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformDriverController.class); + authenticate("platform:overseas-master-data:vehicle:list"); + assertThatThrownBy(() -> controller.detail(71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.history(71, 1, 20)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.download(71, 81)).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(context.getBean(DriverService.class)); + } + } + + private static void authenticate(String permission) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", + List.of(new SimpleGrantedAuthority(permission)))); + } + + @Configuration + @EnableMethodSecurity + static class Config { + @Bean DriverService service() { return mock(DriverService.class); } + @Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java new file mode 100644 index 000000000..71f13d920 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java @@ -0,0 +1,130 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class DriverPersistenceTest { + private JdbcTemplate jdbc; + private DriverService service; + + @BeforeEach void createDatabaseAndTransactionalService() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) { + var match = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;", Pattern.MULTILINE).matcher(schema); + assertThat(match.find()).isTrue(); + // H2 JSON encodes bound strings differently; production schema is checked separately against MySQL baseline. + jdbc.execute(match.group().replace("snapshot_json JSON", "snapshot_json LONGTEXT")); + } + var configuration = new MybatisConfiguration(); + var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + configuration.addInterceptor(pagination); + configuration.addMapper(DriverMapper.class); + configuration.addMapper(DriverHistoryMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(configuration); + var session = new SqlSessionTemplate(factory.getObject()); + var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), + mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()), + Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class)); + var proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (DriverService) proxy.getProxy(); + } + + @Test void historyFailureRollsBackDriverStatusAndVersion() { + var driver = service.create("operator", draft("初始备注")); + jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')"); + assertThatThrownBy(() -> service.changeStatus(Long.parseLong(driver.id()), "e", new DriverModels.ChangeStatus(0L, false, "暂停"))) + .isInstanceOf(RuntimeException.class); + var stored = service.detail(Long.parseLong(driver.id()), null); + assertThat(stored.active()).isTrue(); + assertThat(stored.recordVersion()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(1); + } + + @Test void clearingOptionalFieldsPersistsAndHistoryRemainsImmutable() { + var driver = service.create("operator", draft("原始备注")); + long id = Long.parseLong(driver.id()); + service.update(id, "e", new DriverModels.Update(0L, draft(null))); + assertThat(service.detail(id, null).details().remark()).isNull(); + assertThat(service.history(id, 1, 20).records()).hasSize(2); + assertThat(service.history(id, 1, 20).records().get(1).snapshot().details().remark()).isEqualTo("原始备注"); + } + + @Test void concurrentSameVersionWritesProduceOneWinnerAndOneConflict() throws Exception { + var driver = service.create("operator", draft(null)); + long id = Long.parseLong(driver.id()); + var executor = Executors.newFixedThreadPool(2); + var start = new CountDownLatch(1); + Callable operation = () -> { + start.await(5, TimeUnit.SECONDS); + try { service.changeStatus(id, "e", new DriverModels.ChangeStatus(0L, false, "暂停")); return true; } + catch (org.springframework.web.server.ResponseStatusException e) { + assertThat(e.getStatusCode().value()).isEqualTo(409); + return false; + } + }; + try { + var first = executor.submit(operation); + var second = executor.submit(operation); + start.countDown(); + assertThat(java.util.List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + assertThat(service.detail(id, null).recordVersion()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(2); + } finally { executor.shutdownNow(); } + } + + @Test void summaryAndExpiryFilteringUseLicenseZoneAcrossPages() { + var eastern = new DriverModels.Draft("东侧日期", "123", "OWNED", "OVERSEAS", null, null, null, + java.time.LocalDate.of(2026, 10, 2), "Pacific/Kiritimati", null, null); + var western = new DriverModels.Draft("西侧日期", "123", "DISPATCHED", "OVERSEAS", null, null, null, + java.time.LocalDate.of(2026, 10, 2), "America/Los_Angeles", null, null); + service.create("operator", eastern); + service.create("operator", western); + service.create("operator", draft(null)); + var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null)); + assertThat(all.total()).isEqualTo(3); + assertThat(all.records()).hasSize(1); + assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1)); + var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null)); + assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期"); + assertThat(expired.summary().expired()).isEqualTo(1); + var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null)); + assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING"); + var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null)); + assertThat(missing.total()).isEqualTo(1); + assertThat(missing.summary().expiringSoon()).isZero(); + } + + private static DriverModels.Draft draft(String remark) { + return new DriverModels.Draft("测试司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", remark, null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java new file mode 100644 index 000000000..7ecb74abb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java @@ -0,0 +1,196 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Reason; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.time.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DriverServiceTest { + private final DriverMapper drivers = mock(DriverMapper.class); + private final DriverHistoryMapper history = mock(DriverHistoryMapper.class); + private final DriverFileGateway files = mock(DriverFileGateway.class); + private final Clock clock = Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC); + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private final FleetWarehouseDirectory warehouses = mock(FleetWarehouseDirectory.class); + private final DriverService service = new DriverService(drivers, history, files, json, clock, warehouses); + + @BeforeEach void writesSucceed() { + when(drivers.insert(any(DriverEntity.class))).thenReturn(1); + when(drivers.update(any(DriverEntity.class), any())).thenReturn(1); + when(history.insert(any(DriverHistoryEntity.class))).thenReturn(1); + } + + @Test void draftCanBeSavedWithoutLicenseButCannotDispatchOrBeApproved() throws Exception { + var response = service.create("operator", draft(null)); + assertThat(response.driverNo()).startsWith("DR-").isNotEqualTo(response.id()); + assertThat(response.qualification().reason()).isEqualTo(Reason.MISSING_LICENSE); + assertThat(response.reviewStatus()).isEqualTo("PENDING"); + var captured = ArgumentCaptor.forClass(DriverEntity.class); + verify(drivers).insert(captured.capture()); + when(drivers.lockById(captured.getValue().getId())).thenReturn(captured.getValue()); + assertThatThrownBy(() -> service.review(captured.getValue().getId(), "reviewer", new DriverModels.Review(0L, "APPROVED", null))) + .hasMessageContaining("补齐驾驶证"); + var event = ArgumentCaptor.forClass(DriverHistoryEntity.class); + verify(history).insert(event.capture()); + assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).driverNo()).isEqualTo(response.driverNo()); + verifyNoInteractions(files); + } + + @Test void approvedDriverMayDepartWithReminderOnlyWhenTaskEndsBeforeExpiry() { + var row = row("APPROVED"); + when(drivers.lockById(71)).thenReturn(row); + var response = service.requireDispatchable(71, Instant.parse("2026-10-20T12:00:00Z")); + assertThat(response.qualification().canDispatch()).isTrue(); + assertThat(response.qualification().expiringSoon()).isTrue(); + verify(files).metadata(71, 81); + assertThatThrownBy(() -> service.requireDispatchable(71, Instant.parse("2026-10-23T00:00:00Z"))) + .hasMessageContaining("预计任务结束前到期"); + assertThatThrownBy(() -> service.requireDispatchable(71, null)).hasMessageContaining("预计任务结束时间"); + } + + @Test void approvalVerifiesAttachmentAndPersistsReviewerAndSnapshot() throws Exception { + when(drivers.lockById(71)).thenReturn(row("PENDING")); + var response = service.review(71, "reviewer", new DriverModels.Review(2L, "APPROVED", "已核对")); + assertThat(response.reviewStatus()).isEqualTo("APPROVED"); + assertThat(response.recordVersion()).isEqualTo(3); + verify(files).metadata(71, 81); + var event = ArgumentCaptor.forClass(DriverHistoryEntity.class); + verify(history).insert(event.capture()); + assertThat(event.getValue().getOperatorId()).isEqualTo("reviewer"); + assertThat(event.getValue().getReason()).isEqualTo("已核对"); + assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).reviewStatus()).isEqualTo("APPROVED"); + } + + @Test void failedFileVerificationCannotWriteApproval() { + when(drivers.lockById(71)).thenReturn(row("PENDING")); + when(files.metadata(71, 81)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "错误文件归属")); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("错误文件归属"); + verify(drivers, never()).update(any(DriverEntity.class), any()); + verify(history, never()).insert(any(DriverHistoryEntity.class)); + } + + @Test void rejectsExpiredAndNotYetIssuedLicenseApproval() { + var row = row("PENDING"); + row.setLicenseExpiresOn(LocalDate.of(2026, 10, 1)); + when(drivers.lockById(71)).thenReturn(row); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("已过期"); + row.setLicenseExpiresOn(LocalDate.of(2027, 10, 1)); + row.setLicenseIssuedOn(LocalDate.of(2026, 10, 3)); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("尚未生效"); + verifyNoInteractions(files); + } + + @Test void rejectRequiresReasonAndCanBeExplicitlyResubmitted() { + var row = row("PENDING"); + when(drivers.lockById(71)).thenReturn(row); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "REJECTED", " "))).hasMessageContaining("打回原因"); + assertThat(service.review(71, "r", new DriverModels.Review(2L, "REJECTED", "附件不清楚")).reviewStatus()).isEqualTo("REJECTED"); + assertThat(service.submit(71, "editor", new DriverModels.Submit(3L)).reviewStatus()).isEqualTo("PENDING"); + verify(files).metadata(71, 81); + } + + @Test void credentialEditResetsApprovalButPhoneCorrectionDoesNot() { + var row = row("APPROVED"); + when(drivers.lockById(71)).thenReturn(row); + var phoneOnly = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C", + null, row.getLicenseExpiresOn(), "UTC", null, null); + assertThat(service.update(71, "e", new DriverModels.Update(2L, phoneOnly)).reviewStatus()).isEqualTo("APPROVED"); + var changedExpiry = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C", + null, LocalDate.of(2027, 10, 22), "UTC", null, null); + assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING"); + } + + @Test void replacingLicenseBindsNewFileAndResetsApproval() { + when(drivers.lockById(71)).thenReturn(row("APPROVED")); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes()); + when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9)); + var response = service.uploadLicense(71, 2, "e", file); + assertThat(response.licenseFileId()).isEqualTo("82"); + assertThat(response.reviewStatus()).isEqualTo("PENDING"); + var event = ArgumentCaptor.forClass(DriverHistoryEntity.class); + verify(history).insert(event.capture()); + assertThat(event.getValue().getLicenseFileId()).isEqualTo(82); + } + + @Test void staleVersionFailsBeforeUploadOrReviewAndCasFailureHasNoHistory() { + when(drivers.lockById(71)).thenReturn(row("PENDING")); + assertThatThrownBy(() -> service.uploadLicense(71, 1, "e", null)).hasMessageContaining("已被修改"); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(1L, "APPROVED", null))).hasMessageContaining("已被修改"); + verifyNoInteractions(files); + when(drivers.update(any(DriverEntity.class), any())).thenReturn(0); + assertThatThrownBy(() -> service.changeStatus(71, "e", new DriverModels.ChangeStatus(2L, false, "暂停驾驶"))).hasMessageContaining("已被修改"); + verify(history, never()).insert(any(DriverHistoryEntity.class)); + } + + @Test void unlinkedFileCannotBeReadEvenIfItExistsInFileService() { + when(drivers.selectById(71L)).thenReturn(row("APPROVED")); + when(history.selectCount(any())).thenReturn(0L); + assertThatThrownBy(() -> service.download(71, 99)).hasMessageContaining("未关联"); + verifyNoInteractions(files); + } + + @Test void historicalLinkedFileIsReadableWithExactDriverScope() { + when(drivers.selectById(71L)).thenReturn(row("APPROVED")); + when(history.selectCount(any())).thenReturn(1L); + when(files.metadata(71, 80)).thenReturn(new DriverModels.File("80", "old.pdf", "application/pdf", 8)); + when(files.download(71, 80)).thenReturn(new byte[]{1, 2}); + assertThat(service.download(71, 80).content()).containsExactly(1, 2); + } + + @Test void invalidTimeZoneAndReversedDatesCannotBeSaved() { + var badZone = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "wrong-zone", null, null); + assertThatThrownBy(() -> service.create("e", badZone)).hasMessageContaining("时区"); + var badDates = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, + LocalDate.of(2027, 1, 1), LocalDate.of(2026, 1, 1), "UTC", null, null); + assertThatThrownBy(() -> service.create("e", badDates)).hasMessageContaining("不能早于"); + verify(drivers, never()).insert(any(DriverEntity.class)); + } + + @Test void optionalProfileUsesAuthoritativeWarehouseSnapshotAndDoesNotInventQualificationRequirements() { + when(warehouses.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS")); + var profile = new DriverModels.Profile(92L, "53尺拖车", "中文 / 洛杉矶", LocalDate.of(2020, 1, 1), null, null); + var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", "LIC-1", "C", null, + LocalDate.of(2026, 10, 22), "UTC", null, profile); + var response = service.create("e", details); + assertThat(response.warehouseCode()).isEqualTo("WH-US"); + assertThat(response.warehouseName()).isEqualTo("公共海外仓"); + assertThat(response.details().profile().medicalExpiresOn()).isEqualTo(LocalDate.of(2020, 1, 1)); + var captured = ArgumentCaptor.forClass(DriverEntity.class); + verify(drivers).insert(captured.capture()); + var row = captured.getValue(); row.setLicenseFileId(81L); + when(drivers.lockById(row.getId())).thenReturn(row); + assertThat(service.review(row.getId(), "r", new DriverModels.Review(0L, "APPROVED", null)).reviewStatus()).isEqualTo("APPROVED"); + } + + @Test void invalidWarehouseCannotBePersistedAsFreeText() { + when(warehouses.required(92, "OVERSEAS")).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "仓库已停用")); + var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", null, + new DriverModels.Profile(92L, null, null, null, null, null)); + assertThatThrownBy(() -> service.create("e", details)).hasMessageContaining("仓库已停用"); + verify(drivers, never()).insert(any(DriverEntity.class)); + } + + private static DriverModels.Draft draft(LocalDate expiry) { + return new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, expiry, "UTC", null, null); + } + private static DriverEntity row(String review) { + var row = new DriverEntity(); + row.setId(71L); row.setDriverNo("DR-TEST"); row.setName("司机"); row.setPhone("123"); + row.setOwnership("OWNED"); row.setRegion("OVERSEAS"); row.setActive(true); row.setReviewStatus(review); + row.setLicenseNumber("LIC-1"); row.setLicenseType("C"); row.setLicenseExpiresOn(LocalDate.of(2026, 10, 22)); + row.setLicenseZone("UTC"); row.setLicenseFileId(81L); row.setRecordVersion(2L); + row.setUpdatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); row.setUpdatedBy("editor"); + return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java new file mode 100644 index 000000000..b29ec3ff3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetWarehouseDirectoryTest { + private final FleetWarehouseClient client = mock(FleetWarehouseClient.class); + private final FleetWarehouseDirectory directory = new FleetWarehouseDirectory(client, "test-token"); + + @Test void onlyActiveMatchingOperatingWarehousesCanBeSelected() { + when(client.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of( + new FleetWarehouseClient.Warehouse(1L, "CN", "国内仓", "DOMESTIC", "CN"), + new FleetWarehouseClient.Warehouse(2L, "US", "海外仓", "OVERSEAS", "US"), + new FleetWarehouseClient.Warehouse(3L, "FBA", "目的仓", "FBA", "US"), + new FleetWarehouseClient.Warehouse(4L, "PARTNER", "合作仓", "OVERSEAS_PARTNER", "US")))); + assertThat(directory.options("OVERSEAS")).extracting(DriverModels.WarehouseOption::id).containsExactly("2", "4"); + assertThat(directory.required(1, "DOMESTIC").name()).isEqualTo("国内仓"); + assertThatThrownBy(() -> directory.required(1, "OVERSEAS")).hasMessageContaining("区域不符"); + assertThatThrownBy(() -> directory.required(3, "OVERSEAS")).hasMessageContaining("不存在"); + assertThatThrownBy(() -> directory.required(99, "OVERSEAS")).hasMessageContaining("不存在"); + } + + @Test void failureOrInvalidRegionNeverProducesInventedWarehouseOptions() { + when(client.activeWarehouses("test-token")).thenThrow(new IllegalStateException("unavailable")); + assertThatThrownBy(() -> directory.options("OVERSEAS")).hasMessageContaining("暂不可用"); + assertThatThrownBy(() -> directory.options("unknown")).hasMessageContaining("区域"); + verify(client, times(1)).activeWarehouses("test-token"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java new file mode 100644 index 000000000..bd14ed6ad --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class HttpDriverFileGatewayTest { + @Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() { + assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId()) + .isEqualTo("81"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(5L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "DELIVERY_BOL", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "72", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 82L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + } + @Test void rejectsMissingOversizeOrUnsafeMetadataAndFailedEnvelope() { + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "text/html", 12L), 71, 81L)).hasMessageContaining("不是有效"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 0L), 71, 81L)).hasMessageContaining("不是有效"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 10485761L), 71, 81L)).hasMessageContaining("不是有效"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(null, 71, 81L)).hasMessageContaining("不可用"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(new HttpDriverFileGateway.Envelope("A0400", null), 71, 81L)).hasMessageContaining("不可用"); + } + private static HttpDriverFileGateway.Envelope envelope(Long owner, String type, String driver, Long id, String contentType, Long size) { + return new HttpDriverFileGateway.Envelope("200", new HttpDriverFileGateway.Metadata(id, owner, type, driver, "license.pdf", contentType, size)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java new file mode 100644 index 000000000..d91bc1650 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class HttpVehicleCareFileGatewayTest { + @Test void aSuccessfulHttpEnvelopeCannotBeReturnedAsImageBytes() { + var json = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes()); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.imageContent(json)).hasMessageContaining("不可用"); + var image = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[]{1, 2, 3}); + assertThat(HttpVehicleCareFileGateway.imageContent(image)).containsExactly(1, 2, 3); + } + @Test void metadataMustMatchPlatformRecordCategoryAndFileIdentity() { + assertThat(HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L).fileId()).isEqualTo("81"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(9, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_DRIVER_LICENSE", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "72", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 82, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "text/html"), 71, 81L)).hasMessageContaining("有效照片"); + } + private HttpVehicleCareFileGateway.Envelope envelope(long owner, String type, String record, long file, String mime) { + return new HttpVehicleCareFileGateway.Envelope("200", new HttpVehicleCareFileGateway.Metadata(file, owner, type, record, "meter.png", mime, 8L)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java new file mode 100644 index 000000000..2d1f6ef2c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; + +class VehicleCareApiContractTest { + @Test void operationsPermissionsAndSchemasMatchTheirPublishedContracts() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-records-v1.yaml"))); + Map paths = (Map)document.get("paths"); + String permissionSql = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + String initialization = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + String base = PlatformVehicleCareController.class.getAnnotation(RequestMapping.class).value()[0]; + int count = 0; + for (var method : PlatformVehicleCareController.class.getDeclaredMethods()) { + String verb = null; String[] suffix = null; + if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); } + if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); } + if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); } + if (verb == null) continue; + String path = base + (suffix.length == 0 ? "" : suffix[0]); + assertThat(paths.containsKey(path)).as(path).isTrue(); + Map operation = (Map)((Map)paths.get(path)).get(verb); + String expression = method.getAnnotation(PreAuthorize.class).value(); + String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\'')); + assertThat(operation.get("description").toString()).contains(permission); + assertThat(permissionSql).contains("'" + permission + "'"); assertThat(initialization).contains("'" + permission + "'"); count++; + } + assertThat(count).isEqualTo(7); + for (String table : List.of("ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) { + var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;"); + var module = pattern.matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + var deployed = pattern.matcher(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))); + assertThat(module.find()).isTrue(); assertThat(deployed.find()).isTrue(); + assertThat(module.group().replace("\r\n", "\n")).isEqualTo(deployed.group().replace("\r\n", "\n")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java new file mode 100644 index 000000000..8fdde5eab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCareAuthorizationTest { + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + @Test void vehicleListReadsRecordsButDoesNotAuthorizeCreateEditOrPhotoUpload() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformVehicleCareController.class); + var service = context.getBean(VehicleCareService.class); + authenticate("platform:overseas-master-data:vehicle:list"); + controller.detail(1, "VR-1"); verify(service).detail(1, "VR-1"); + assertThatThrownBy(() -> controller.create(1, null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.update(1, "VR-1", null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.upload(1, "VR-1", null, 0, "key", null)).isInstanceOf(AccessDeniedException.class); + authenticate("platform:overseas-master-data:driver:list"); + assertThatThrownBy(() -> controller.detail(1, "VR-1")).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.download(1, "VR-1", 1)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + } + private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); } + @Configuration @EnableMethodSecurity static class Config { + @Bean VehicleCareService service() { return mock(VehicleCareService.class); } + @Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java new file mode 100644 index 000000000..026801b22 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java @@ -0,0 +1,166 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCarePersistenceTest { + private JdbcTemplate jdbc; + private VehicleCareService service; + private VehicleCareFileGateway files; + private long vehicleA, vehicleB; + private final Instant occurred = Instant.parse("2026-01-01T00:00:00Z"); + @BeforeEach void setup() throws Exception { + var source = new DriverManagerDataSource("jdbc:h2:mem:care-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String table : List.of("ll_vehicle", "ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) { + var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); + assertThat(match.find()).as(table).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + } + var config = new MybatisConfiguration(); + config.addMapper(VehicleMapper.class); config.addMapper(VehicleCareMapper.class); config.addMapper(VehicleCareHistoryMapper.class); + var interceptor = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + config.addInterceptor(interceptor); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config); + var session = new SqlSessionTemplate(factory.getObject()); + var vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class)); + vehicleA = vehicles.create(vehicle("VEH-A")); vehicleB = vehicles.create(vehicle("VEH-B")); + files = mock(VehicleCareFileGateway.class); + when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8)); + var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files, + new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (VehicleCareService) proxy.getProxy(); + } + @Test void recordsAreScopedToVehicleAndNeverRewriteMasterOdometerOrCreateAnotherRecordOnRetry() { + var request = new VehicleCareModels.Create("key", maintenance()); + var a = service.create(vehicleA, "1", request); + assertThat(service.create(vehicleA, "1", request).recordNo()).isEqualTo(a.recordNo()); + var b = service.create(vehicleB, "1", request); + assertThat(b.recordNo()).isNotEqualTo(a.recordNo()); + assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, "MAINTENANCE")).records()).extracting(VehicleCareModels.Response::recordNo).containsExactly(a.recordNo()); + assertThatThrownBy(() -> service.detail(vehicleB, a.recordNo())).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.update(vehicleB, a.recordNo(), "1", new VehicleCareModels.Update(0L, "错误车辆", maintenance()))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.download(vehicleB, a.recordNo(), 9001)).hasMessageContaining("不存在"); + assertThat(jdbc.queryForList("SELECT mileage_km FROM ll_vehicle", Long.class)).containsOnly(1000L); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(2); + } + @Test void correctionKeepsImmutableHistoryAndRejectsStaleVersionOrChangedType() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", maintenance())); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()))).hasMessageContaining("其他运维记录"); + assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "变更类型", fuel()))).hasMessageContaining("不能改变"); + service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "补充核对", maintenance())); + assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "旧页面", maintenance()))).hasMessageContaining("已变化"); + var history = service.history(vehicleA, row.recordNo(), 1, 20).records(); + assertThat(history).extracting(VehicleCareModels.History::action).containsExactly("UPDATE", "CREATE"); + assertThat(history.get(1).snapshot().updatedBy()).isEqualTo("1"); + } + @Test void failedHistoryWriteRollsBackUpdate() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel())); + jdbc.execute("ALTER TABLE ll_fleet_vehicle_record_history ADD CONSTRAINT fail_update CHECK(action <> 'UPDATE')"); + assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "更正", fuel()))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(vehicleA, row.recordNo()).recordVersion()).isZero(); + assertThat(service.detail(vehicleA, row.recordNo()).updatedBy()).isEqualTo("1"); + } + @Test void uploadRetryReturnsOriginalAssociationAndRejectsAnotherImageOrForeignRecord() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel())); + var image = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1,2,3}); + var uploaded = service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image); + assertThat(uploaded.files()).hasSize(1); + assertThat(service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image).recordVersion()).isEqualTo(1); + verify(files, times(1)).upload(anyLong(), eq("1"), any()); + assertThatThrownBy(() -> service.upload(vehicleA, row.recordNo(), 1, "upload-key", "1", + new MockMultipartFile("file", "other.png", "image/png", new byte[]{4}))).hasMessageContaining("不同图片"); + assertThatThrownBy(() -> service.upload(vehicleB, row.recordNo(), 1, "new-key", "1", image)).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.download(vehicleA, row.recordNo(), 9999)).hasMessageContaining("不存在"); + verify(files, never()).metadata(anyLong(), anyLong()); + } + @Test void explicitUnitsAndTypedFieldsAreRequiredButHistoricalOdometersMayBeBelowTheMaster() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("fuel", fuel())); + assertThat(row.details().distanceUnit()).isEqualTo("MI"); + assertThat(row.details().fuelUnit()).isEqualTo("US_GAL"); + assertThat(row.details().odometer()).isEqualByComparingTo("800"); + var bad = new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("10"), "GAL", "PENDING", null, null); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("bad", bad))).hasMessageContaining("明确"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1); + } + @Test void concurrentDuplicateCreationCreatesOneBusinessRecordAndOneHistory() throws Exception { + var request = new VehicleCareModels.Create("concurrent", fuel()); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + Callable task = () -> { start.await(5, TimeUnit.SECONDS); return service.create(vehicleA, "1", request).recordNo(); }; + try { + var first = executor.submit(task); var second = executor.submit(task); start.countDown(); + assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + @Test void incidentTypesKeepProjectsPlacesAndProcessingFactsIsolatedPerVehicle() { + for (String type : List.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION")) { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create(type, incident(type, "PENDING", null, null, null))); + assertThat(service.detail(vehicleA, row.recordNo()).details().incident()).isEqualTo(row.details().incident()); + assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, type)).records()).hasSize(1); + assertThat(service.page(vehicleB, new VehicleCareModels.Query(1, 20, type)).total()).isZero(); + assertThatThrownBy(() -> service.update(vehicleB, row.recordNo(), "1", new VehicleCareModels.Update(0L, "更新处理", row.details()))).hasMessageContaining("不存在"); + if (List.of("REPAIR", "RESCUE").contains(type)) { + assertThat(row.details().incident().item()).isEqualTo("更换轮胎"); + assertThat(row.details().incident().place()).isEqualTo("实际作业地点"); + assertThat(row.paymentStatus()).isEqualTo("UNLINKED"); + } + } + } + @Test void completedRepairDoesNotInventPaymentOrReleaseAndChangingCostKeepsHistory() { + var created = service.create(vehicleA, "1", new VehicleCareModels.Create("repair", incident("REPAIR", "PENDING", null, null, null))); + var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD"))); + var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD"))); + assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0"); + assertThat(completed.paymentStatus()).isEqualTo("UNLINKED"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE"); + var history = service.history(vehicleA, created.recordNo(), 1, 20).records(); + assertThat(history).hasSize(3); + assertThat(history.get(1).snapshot().details().incident().reportedCost()).isEqualByComparingTo("120.50"); + assertThat(history.get(2).snapshot().details().incident().reportedCost()).isNull(); + assertThatThrownBy(() -> service.update(vehicleA, created.recordNo(), "1", new VehicleCareModels.Update(0L, "旧请求", created.details()))).hasMessageContaining("已变化"); + } + @Test void incidentValidationRejectsMissingHandlersCurrencyAndMixedFuelFields() { + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-handler", incident("RESCUE", "COMPLETED", null, null, null)))).hasMessageContaining("处理人"); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-currency", incident("RESCUE", "PENDING", null, BigDecimal.ZERO, null)))).hasMessageContaining("币种"); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("invalid-currency", incident("RESCUE", "PENDING", null, BigDecimal.ONE, "ZZZ")))).hasMessageContaining("币种无效"); + var detail = incident("EXCEPTION", "PENDING", null, null, null); + var mixed = new VehicleCareModels.Draft(detail.recordType(), occurred, "KM", BigDecimal.ONE, null, null, null, null, null, null, detail.incident()); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("mixed", mixed))).hasMessageContaining("不能混入"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isZero(); + } + private VehicleCareModels.Draft incident(String type, String status, String handler, BigDecimal cost, String currency) { + boolean priced = List.of("REPAIR", "RESCUE").contains(type); + return new VehicleCareModels.Draft(type, occurred, null, null, null, null, null, null, null, null, + new VehicleCareModels.Incident("现场轮胎故障及处理情况", priced ? "更换轮胎" : null, priced ? "实际作业地点" : null, + handler, status, "EXCEPTION".equals(type) ? "VEHICLE_BREAKDOWN" : null, cost, currency)); + } + private VehicleCareModels.Draft maintenance() { return new VehicleCareModels.Draft("MAINTENANCE", occurred, "KM", new BigDecimal("800"), new BigDecimal("500"), new BigDecimal("700"), null, null, "PENDING", "已超过计划保养里程", null); } + private VehicleCareModels.Draft fuel() { return new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("28.6"), "US_GAL", "PENDING", null, null); } + private VehicleRepository.NewVehicle vehicle(String plate) { return new VehicleRepository.NewVehicle(plate, "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), 30, new BigDecimal("18000"), new BigDecimal("90")); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java new file mode 100644 index 000000000..1c4992e03 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetBookingFreightTest { + final FleetBookingMapper bookings = mock(FleetBookingMapper.class); + final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class); + final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json); + final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json); + FleetBookingEntity booking; + final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8"); + @BeforeEach void setup() throws Exception { + booking = new FleetBookingEntity(); + booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1"); + booking.setDispatchNo("DP-1"); booking.setCustomerId("C-1"); booking.setCustomerName("客户甲"); + booking.setDestinationKey("FBA:ONT8"); booking.setStatus("BOUND"); booking.setRecordVersion(4L); + booking.setAgreedAmount(new BigDecimal("60.00")); + booking.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8", "测试货物", 12, 2, + BigDecimal.TEN, BigDecimal.ONE, "PALLET", new BigDecimal("30.00"), "CNY", null, null, null))); + booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt( + new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1", + List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12), + List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z")))); + var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L); + when(bindings.findByDispatchNo("DP-1")).thenReturn(binding); + when(bookings.incomingForFreight("WT-1")).thenReturn(booking); + } + @Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() { + var preview = service.preview(3, "DP-1", "C-1", List.of(source)); + assertFalse(preview.requiresDeliveryFee()); assertEquals(new BigDecimal("60.00"), preview.quotes().get(0).amount()); + assertEquals("IN-1", preview.quotes().get(0).inboundCode()); + var other = new FleetDispatchGateway.SourceCargo("IN-OTHER", "502", "PLT-502", 8, "FBA:LAX9"); + var mixed = service.preview(3, "DP-1", "C-1", List.of(source, other)); + assertTrue(mixed.requiresDeliveryFee()); assertEquals(1, mixed.quotes().size()); + assertEquals(mixed.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(other, source)).snapshotHash()); + } + @Test void rejectsUnboundDifferentCustomerOrWrongTrip() { + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-2", List.of(source))); + booking.setStatus("RECEIVED"); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source))); + booking.setStatus("BOUND"); booking.setTripId(99L); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source))); + } + @Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() { + var before = service.preview(3, "DP-1", "C-1", List.of(source)); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( + new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8")))); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( + new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9")))); + booking.setRecordVersion(5L); + assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash()); + } + @Test void cancellingTheBookingDoesNotEraseThePreviousFinancialSource() throws Exception { + booking.setStatus("CANCELLED"); booking.setDispatchNo(null); + var preview = service.preview(9, "DP-NEW", "C-1", List.of(source)); + assertTrue(preview.requiresDeliveryFee()); assertTrue(preview.quotes().isEmpty()); + assertEquals(List.of("IN-1"), preview.priorInboundCodes()); + assertFalse(json.writeValueAsString(preview).contains("priorInboundCodes")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java new file mode 100644 index 000000000..a1c84d7e0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; + +class FleetTripApiContractTest { + @Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml"))); + Map paths = (Map) document.get("paths"); + String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + int count = 0; + for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) { + String base = controller.getAnnotation(RequestMapping.class).value()[0]; + for (var method : controller.getDeclaredMethods()) { + String verb = null; String[] suffix = null; + if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); } + if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); } + if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); } + if (verb == null) continue; + String path = base + (suffix.length == 0 ? "" : suffix[0]); + assertThat(paths.containsKey(path)).as(path).isTrue(); + Map operation = (Map) ((Map) paths.get(path)).get(verb); + String expression = method.getAnnotation(PreAuthorize.class).value(); + var authorities = Pattern.compile("'([^']+)'").matcher(expression); + int permissionCount=0; + while(authorities.find()) { + String permission=authorities.group(1); + assertThat(operation.get("description").toString()).contains(permission); + assertThat(module).contains("'" + permission + "'"); + assertThat(deployed).contains("'" + permission + "'"); + permissionCount++; + } + assertThat(permissionCount).isPositive(); + count++; + } + } + assertThat(count).isEqualTo(28); + verifyRefs(document, document); + } + + @Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception { + String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (String table : java.util.List.of("ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", "ll_fleet_trip_dispatch", "ll_fleet_external_booking")) { + var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;"); + var a = pattern.matcher(module); var b = pattern.matcher(deployed); + assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue(); + assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n")); + } + assertThat(deployed).contains("PRIMARY KEY (resource_kind,resource_id)") + .contains("UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key)") + .contains("UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version)"); + } + + private static void verifyRefs(Object node, Map root) { + if (node instanceof Map map) { + if (map.get("$ref") instanceof String reference) { + assertThat(reference).startsWith("#/"); + Object value = root; + for (String key : reference.substring(2).split("/")) value = ((Map) value).get(key); + assertThat(value).as(reference).isNotNull(); + } + map.values().forEach(value -> verifyRefs(value, root)); + } else if (node instanceof Iterable values) values.forEach(value -> verifyRefs(value, root)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java new file mode 100644 index 000000000..014c19c1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTripAuthorizationTest { + @Test void listPermissionCannotReserveOrReleaseResources() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); + var controller = context.getBean(PlatformFleetTripController.class); + var service = context.getBean(FleetTripService.class); + controller.detail("TR-TEST"); verify(service).detail("TR-TEST"); + assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.assign(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.reviewLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.complete(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.release(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.confirmCapacityPlan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + var booking = context.getBean(PlatformFleetBookingController.class); + booking.list("TR-TEST"); verify(context.getBean(FleetBookingService.class)).list("TR-TEST"); + assertThatThrownBy(() -> booking.create(null,"TR-TEST",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.update(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.confirm(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.cancel(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.incoming(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.receipt(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.bind(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Configuration @EnableMethodSecurity + static class Config { + @Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); } + @Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); } + @Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); } + @Bean FleetTripService service() { return mock(FleetTripService.class); } + @Bean FleetTripExecutionService execution() { return mock(FleetTripExecutionService.class); } + @Bean FleetTripCompletionService completion() { return mock(FleetTripCompletionService.class); } + @Bean PlatformFleetTripController controller(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) { return new PlatformFleetTripController(service, execution, completion); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java new file mode 100644 index 000000000..dfc5a42c9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -0,0 +1,866 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import org.springframework.test.util.ReflectionTestUtils; +import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FleetTripPersistenceTest { + private JdbcTemplate jdbc; + private FleetTripService service; + private DriverService drivers; + private VehicleRepository vehicles; + private VehicleApplicationService vehicleService; + private DataSourceTransactionManager transactions; + private FleetDispatchBindingGuard bindingGuard; + private FleetTripExecutionService execution; + private FleetTripCompletionService completion; + private FleetBookingService bookingService; + private FleetBookingIncomingGateway incoming; + private Instant departure, end; + + @BeforeEach void initialize() throws Exception { + departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS); + end = departure.plus(12, ChronoUnit.HOURS); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", + "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", + "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation")) { + var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); + assertThat(match.find()).as(table).isTrue(); + jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + } + var configuration = new MybatisConfiguration(); + for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, + FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class, + DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper); + var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + configuration.addInterceptor(pagination); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var session = new SqlSessionTemplate(factory.getObject()); + var directory = mock(FleetWarehouseDirectory.class); + when(directory.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS")); + var files = mock(DriverFileGateway.class); + when(files.upload(anyLong(), anyString(), any())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(0, Long.class) + 1000), "license.pdf", "application/pdf", 8)); + when(files.metadata(anyLong(), anyLong())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(1, Long.class)), "license.pdf", "application/pdf", 8)); + var json = new ObjectMapper().registerModule(new JavaTimeModule()); + drivers = transactional(new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), files, json, directory), DriverService.class); + vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class)); + var resources = session.getMapper(FleetResourceMapper.class); + vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class); + var bookingMapper = session.getMapper(FleetBookingMapper.class); + var reservations = new FleetBookingReservations(bookingMapper, json); + var bindingMapper = session.getMapper(FleetTripDispatchMapper.class); + bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class); + var workflow = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class)); + ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); + ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class)); + ReflectionTestUtils.setField(workflow, "tasks", session.getMapper(DispatchPdaTaskMapper.class)); + ReflectionTestUtils.setField(workflow, "picks", session.getMapper(DispatchPdaPickMapper.class)); + ReflectionTestUtils.setField(workflow, "operations", session.getMapper(DispatchPdaOperationMapper.class)); + ReflectionTestUtils.setField(workflow, "palletLocks", session.getMapper(DispatchPalletLockMapper.class)); + ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard); + ReflectionTestUtils.setField(workflow, "objectMapper", json); + ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class)); + var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class), + session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class), + transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); + service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, + bindingMapper, dispatchGateway, reservations), FleetTripService.class); + execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); + var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); + when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( + Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null)); + incoming=mock(FleetBookingIncomingGateway.class); + bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming), FleetBookingService.class); + completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); + } + + @Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() { + var request = createRequest("creation-one"); + var first = service.create("1", request); + assertThat(service.create("1", request).trip().tripNo()).isEqualTo(first.trip().tripNo()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1); + assertThatThrownBy(() -> service.create("1", new FleetTripModels.Create(92L, departure, end.plusSeconds(1), request.stops(), null, request.idempotencyKey()))) + .hasMessageContaining("不同车次内容"); + assertThat(service.create("2", request).trip().tripNo()).isNotEqualTo(first.trip().tripNo()); + } + + @Test void assignmentLocksBothResourcesAndCancellationReleasesThemWithoutRemovingHistory() { + long vehicle = vehicle(true), driver = driver(true); + String first = trip(), second = trip(); + var assigned = service.assign(first, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排运输")); + assertThat(assigned.driverQualification().canDispatch()).isTrue(); + assertThat(assigned.trip().maxPalletSpaces()).isEqualTo(30); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重复分配"))).hasMessageContaining("占用"); + assertThat(service.cancel(first, "1", new FleetTripModels.Cancel(1L, "取消安排")).trip().status()).isEqualTo("CANCELLED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + assertThat(service.history(first, 1, 20).records()).extracting(FleetTripModels.History::action).containsExactly("CANCEL", "ASSIGN", "CREATE"); + assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle)); + } + + @Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() { + long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true); + long oldDriver = driver(true), occupiedDriver = driver(true); + String first = trip(), second = trip(); + service.assign(first, "1", new FleetTripModels.Assign(0L, oldVehicle, oldDriver, "安排")); + service.assign(second, "1", new FleetTripModels.Assign(0L, occupiedVehicle, occupiedDriver, "安排")); + assertThatThrownBy(() -> service.assign(first, "1", new FleetTripModels.Assign(1L, replacement, occupiedDriver, "改派"))).hasMessageContaining("占用"); + var retained = service.detail(first).trip(); + assertThat(retained.vehicleId()).isEqualTo(Long.toString(oldVehicle)); + assertThat(retained.recordVersion()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, replacement)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(4); + } + + @Test void missingCapacityOrUnapprovedDriverCannotBeAssigned() { + String number = trip(); + long unknownCapacity = vehicle(false), validDriver = driver(true); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, unknownCapacity, validDriver, "安排"))).hasMessageContaining("托位、载重和容积"); + long validVehicle = vehicle(true), missingLicense = driver(false); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, validVehicle, missingLicense, "安排"))).hasMessageContaining("驾驶证"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + assertThat(service.detail(number).trip().recordVersion()).isZero(); + } + + @Test void extendingPlanPastLicenseExpiryFailsAndKeepsTheOldPlan() { + String number = trip(); + service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(1L, departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期"))) + .hasMessageContaining("预计任务结束前到期"); + assertThat(service.detail(number).trip().expectedDriverEndAt()).isEqualTo(end); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1); + } + + @Test void failedHistoryWriteRollsBackTripAndAllResourceClaims() { + String number = trip(); long vehicle = vehicle(true), driver = driver(true); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_assignment CHECK(action <> 'ASSIGN')"); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(number).trip().vehicleId()).isNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + assertThat(service.detail(number).trip().recordVersion()).isZero(); + } + + @Test void activeTripPreventsMasterDataFromChangingPlateCapacityOrStatus() { + long vehicle = vehicle(true); String number = trip(); + service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver(true), "安排")); + assertThatThrownBy(() -> vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L))).hasMessageContaining(number); + var original = vehicles.findById(vehicle).orElseThrow(); + assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, "CHANGED", original.model(), original.fuelConsumptionPer100Km(), + original.mileageKm(), null, null, original.region(), original.ownership(), null, 30, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number); + assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, original.plateNumber(), original.model(), original.fuelConsumptionPer100Km(), + original.mileageKm(), null, null, original.region(), original.ownership(), null, 20, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number); + service.cancel(number, "1", new FleetTripModels.Cancel(1L, "取消")); + assertThat(vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L)).status()).isEqualTo("MAINTENANCE"); + } + + @Test void simultaneousVehicleAssignmentHasExactlyOneWinner() throws Exception { + String first = trip(), second = trip(); long vehicle = vehicle(true), driver1 = driver(true), driver2 = driver(true); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> attempt(start, first, vehicle, driver1)); + var b = executor.submit(() -> attempt(start, second, vehicle, driver2)); + start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE'", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + + @Test void concurrentCreationWithTheSameKeyCreatesOneTripAndOneHistory() throws Exception { + var create = createRequest("concurrent-create"); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + Callable command = () -> { start.await(5, TimeUnit.SECONDS); return service.create("1", create).trip().tripNo(); }; + try { + var first = executor.submit(command); var second = executor.submit(command); start.countDown(); + assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + + @Test void tripCombinesCustomersAndSynchronizesSnapshotsWithoutChangingTheirOwnershipOrArrivalTime() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入本车")); + var bound = service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入本车")); + assertThat(bound.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A", "CLIENT-B"); + long replacement = vehicle(true); + var reassigned = service.assign(number, "1", new FleetTripModels.Assign(3L, replacement, driver(true), "更换车辆司机")); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(reassigned.trip().vehiclePlate()); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(reassigned.trip().vehiclePlate()); + assertThat(jdbc.queryForList("SELECT customer_id FROM ll_delivery_order ORDER BY id", String.class)).containsExactly("CLIENT-A", "CLIENT-B"); + assertThat(jdbc.queryForList("SELECT version FROM ll_delivery_order", Long.class)).containsOnly(2L); + assertThat(jdbc.queryForObject("SELECT expected_arrival_at FROM ll_delivery_order WHERE id=2011", LocalDateTime.class)).isEqualTo(LocalDateTime.of(2030, 1, 1, 12, 0)); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().dispatches()).hasSize(2); + } + + @Test void unbindClearsCurrentSnapshotsButKeepsHistoryAndRequiresUnpickedCargo() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + assertThatThrownBy(() -> service.cancel(number, "1", new FleetTripModels.Cancel(2L, "取消"))).hasMessageContaining("先解除"); + var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireUnbound(11); return null; })).hasMessageContaining(number); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireCoordinatedDeparture(11); return null; })).hasMessageContaining("统一发车"); + assertThat(execution.gate("92", "DP-11").tripNo()).isEqualTo(number); + var assigned = service.detail(number).trip(); + var actual = new FleetDispatchGateway.Assignment(assigned.vehiclePlate(), assigned.driverName(), assigned.driverPhone()); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, + new FleetDispatchGateway.Assignment("OTHER-PLATE", null, null)); return null; })).hasMessageContaining("统一维护"); + tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, new FleetDispatchGateway.Assignment(null, null, null)); return null; }); + jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=11"); + assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"))).hasMessageContaining("已开始执行"); + jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=0 WHERE id=11"); + service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下")); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT driver_name FROM ll_delivery_order WHERE id=2011", String.class)).isNull(); + assertThat(service.history(number, 1, 20).records().get(1).snapshot().dispatches()).hasSize(1); + assertThat(service.detail(number).dispatches()).isEmpty(); + service.cancel(number, "1", new FleetTripModels.Cancel(3L, "取消")); + } + + @Test void wrongWarehouseAndStaleVersionCannotBindOrMutateCustomerDocuments() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "93"); + assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).hasMessageContaining("同一起运仓"); + jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=11"); + assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(0L, "DP-11", "加入"))).hasMessageContaining("已被修改"); + assertThat(service.detail(number).dispatches()).isEmpty(); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); + } + + @Test void bindingHistoryFailureRollsBackAssociationAndEveryDownstreamSnapshot() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A"); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_binding CHECK(action <> 'BIND_DISPATCH')"); + assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(number).dispatches()).isEmpty(); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1L); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_delivery_order WHERE id=2011", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT version FROM ll_delivery_order WHERE id=2011", Long.class)).isZero(); + } + + @Test void failedReassignmentOfOneExecutedMemberRollsBackOtherMembersAndResourceClaims() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); + String original = service.detail(number).trip().vehiclePlate(); + jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=10 WHERE id=12"); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(3L, vehicle(true), driver(true), "改派"))).hasMessageContaining("已开始执行"); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(original); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(3L); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(3L, departure, end.plusSeconds(60), stops(), null, "延后"))) + .hasMessageContaining("已开始执行"); + } + + @Test void concurrentBindingToDifferentTripsHasExactlyOneWinner() throws Exception { + String first = assignedTrip(), second = assignedTrip(); dispatch(11, "CLIENT-A", "92"); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> bindAttempt(start, first)); var b = executor.submit(() -> bindAttempt(start, second)); start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='BIND_DISPATCH'", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + + @Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() { + String number=readyTrip();var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create")); + var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); + var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); + when(incoming.create(eq("1"),any())).thenReturn(awaiting); + var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start"); + var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); + assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); + verify(incoming,times(1)).create(eq("1"),any()); + assertThat(started.booking().status()).isEqualTo("CONFIRMED"); + var actual=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_CUSTOMER_CONFIRMATION","actual-receipt", + List.of(new FleetBookingIncomingGateway.Pallet("902","PALLET-902",22,new BigDecimal("150"),new BigDecimal("1.5"))),22); + when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual); + when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual); + assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成"); + assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00"); + when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); + var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt")); + assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22); + assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00"); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + dispatch(13,"9","92"); + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); + var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); + assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13"); + assertThat(execution.capacityPlan(number).planValid()).isFalse(); + assertThat(service.detail(number).dispatches()).hasSize(3); + service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联")); + assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); + assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); + } + + @Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() { + String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one"); + var draft=bookingService.create(number,"1",create); + assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo()); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); + var command=new FleetBookingModels.Command(draft.tripRecordVersion(),draft.booking().recordVersion(),"external-confirm-one",null); + var accepted=bookingService.confirm(number,draft.booking().bookingNo(),"1",command); + assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("500"); + assertThat(bookingService.confirm(number,draft.booking().bookingNo(),"1",command).replayed()).isTrue(); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(19); + assertThat(execution.gate("92","DP-11").blockReason()).contains("外接预订"); + assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad()); + var cancel=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"external-cancel-one",null); + var cancelled=bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel); + assertThat(bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel).tripRecordVersion()).isEqualTo(cancelled.tripRecordVersion()); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); + assertThat(execution.gate("92","DP-11").canDepart()).isTrue(); + assertThat(service.history(number,1,20).records().stream().filter(row->row.action().equals("BOOKING_CONFIRM")).findFirst().orElseThrow() + .snapshot().externalBookings().get(0).status()).isEqualTo("CONFIRMED"); + } + + @Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() { + String number=readyTrip();var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first")); + bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null)); + var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second")); + assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆"); + assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT"); + var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1); + assertThatThrownBy(()->execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(second.tripRecordVersion(),basis.manifestHash(),List.of(excessive),"增加自有货计划"))).hasMessageContaining("超过车辆"); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(9); + var changed = service.updatePlan(number,"1",new FleetTripModels.Plan(second.tripRecordVersion(),departure,end.plusSeconds(60),stops(),null,"调整时间")); + assertThat(execution.capacityPlan(number).capacityPlan()).isNull(); + service.assign(number,"1",new FleetTripModels.Assign(changed.trip().recordVersion(),Long.valueOf(changed.trip().vehicleId()),driver(true),"同车换司机")); + assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("CONFIRMED"); + } + private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);} + + @Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() { + String number = readyTrip(); + jdbc.update("DELETE FROM ll_dispatch_pda_pick"); + jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=0"); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='PDA_PENDING'"); + jdbc.update("UPDATE ll_fleet_trip_dispatch SET review_json=NULL"); + var basis = execution.capacityPlan(number); + assertThat(basis.planValid()).isFalse(); assertThat(basis.blockReason()).isNull(); + var plan = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "计划载荷")); + assertThat(plan.planValid()).isTrue(); + assertThat(plan.capacityPlan().pallets()).hasSize(1); + assertThat(plan.capacityPlan().segments().get(0).remaining().palletSpaces()).isEqualTo(29); + assertThat(execution.loading(number).confirmedLoad()).isNull(); + assertThat(execution.gate("92", "DP-11").canPick()).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick", Integer.class)).isZero(); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().capacityPlan()).isEqualTo(plan.capacityPlan()); + var unchanged = service.updatePlan(number, "1", new FleetTripModels.Plan(plan.recordVersion(), departure, end, stops(), "备注", "补充备注")); + assertThat(execution.capacityPlan(number).planValid()).isTrue(); + service.assign(number, "1", new FleetTripModels.Assign(unchanged.trip().recordVersion(), vehicle(true), driver(true), "更换资源")); + assertThat(execution.capacityPlan(number).capacityPlan()).isNull(); + } + + @Test void capacityPlanRejectsStaleCargoAndOverbookingWithoutAffectingConfirmedActualLoad() { + String number = readyTrip(); var actual = confirm(number); var basis = execution.capacityPlan(number); + var tooHeavy = new FleetExecutionModels.Measurement("901", 1, new BigDecimal("19000"), new BigDecimal("1"), 1); + assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(tooHeavy), "预估"))).hasMessageContaining("超过车辆"); + var planned = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "预估")); + assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad()); + assertThat(service.detail(number).loadingReview().status()).isEqualTo("APPROVED"); + jdbc.update("UPDATE ll_dispatch_item SET package_count=11 WHERE id=3011"); + assertThat(execution.capacityPlan(number).planValid()).isFalse(); + assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(planned.recordVersion(), basis.manifestHash(), List.of(measurement()), "旧页面"))).hasMessageContaining("货物明细已变化"); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(planned.recordVersion()); + } + + @Test void dispatchReviewsAreIndependentAndRejectionBlocksOnlyNewPickingForThatCustomer() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); + var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; })).hasMessageContaining("调度审核"); + service.reviewDispatch(number, "DP-11", "1", new FleetTripModels.Review(3L, "APPROVED", null)); + var gates = execution.gates("92", List.of("DP-11", "DP-12")); + assertThat(gates.get("DP-11").canPick()).isTrue(); + assertThat(gates.get("DP-12").canPick()).isFalse(); + tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; }); + assertThatThrownBy(() -> service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", " "))).hasMessageContaining("打回原因"); + service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", "核对预约时间")); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(12); return null; })).hasMessageContaining("核对预约时间"); + service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(5L, "APPROVED", null)); + assertThat(service.detail(number).dispatches()).allMatch(row -> "APPROVED".equals(row.review().status())); + } + + @Test void loadingReviewRequiresActualLoadAndRemarkOnlyEditPreservesApprovals() { + String number = readyTrip(); var loading = execution.loading(number); + assertThatThrownBy(() -> execution.reviewLoading(number, "1", new FleetTripModels.Review(loading.recordVersion(), "APPROVED", null))).hasMessageContaining("确认本车实际装载"); + var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "核对装载")); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("理货审核"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "before-review", null))).hasMessageContaining("理货审核"); + var rejected = execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "REJECTED", "重新核对重量")); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("重新核对重量"); + var approved = execution.reviewLoading(number, "1", new FleetTripModels.Review(rejected.trip().recordVersion(), "APPROVED", null)); + var changed = service.updatePlan(number, "1", new FleetTripModels.Plan(approved.trip().recordVersion(), departure, end, stops(), "补充备注", "更新备注")); + assertThat(changed.loadingReview().status()).isEqualTo("APPROVED"); + assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); + execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(changed.trip().recordVersion(), loading.manifestHash(), List.of(measurement()), "重新测量")); + assertThat(service.detail(number).loadingReview().status()).isEqualTo("PENDING"); + assertThat(execution.gate("92", "DP-11").canDepart()).isFalse(); + } + + @Test void actualLoadingCountsSharedPhysicalPalletOnceAndDepartureShipsAllCustomersAtomically() { + String number = readyTrip(); + assertThat(execution.gate("92", "DP-11").canDepart()).isFalse(); + var loading = confirm(number); + assertThat(loading.confirmedLoad().cargo()).hasSize(2); + assertThat(loading.confirmedLoad().segments().get(0).occupied().palletSpaces()).isEqualTo(1); + assertThat(loading.confirmedLoad().segments().get(0).occupied().weightKg()).isEqualByComparingTo("100"); + assertThat(execution.gates("92", List.of("DP-11", "DP-12")).values()).allMatch(FleetExecutionModels.Gate::canDepart); + assertThat(execution.gate("92", "DP-11").driverWarning()).contains("有效期覆盖本次预计任务"); + var request = new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "整车发车"); + var result = execution.depart("92", "DP-11", "1", request); + assertThat(result.shippedPackages()).isEqualTo(20); + assertThat(result.dispatches()).extracting(FleetExecutionModels.DepartedDispatch::dispatchNo).containsExactly("DP-11", "DP-12"); + assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("DEPARTED"); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED"); + assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1); + assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); + assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "changed"))) + .hasMessageContaining("内容不一致"); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().confirmedLoad().cargo()).hasSize(2); + } + + @Test void reassigningPickedTripPreservesWorkAndRequiresFreshLoadingOnTheReplacement() { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id"); + var tasksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id"); + var arrivals = jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id"); + long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456"); + var changed = service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "发车前换车及司机,重新核对装载")); + assertThat(changed.trip().status()).isEqualTo("PLANNED"); + assertThat(changed.trip().vehicleId()).isEqualTo(Long.toString(replacement)); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(changed.trip().vehiclePlate()); + assertThat(jdbc.queryForList("SELECT driver_name FROM ll_delivery_order", String.class)).containsOnly("替班司机"); + assertThat(jdbc.queryForList("SELECT driver_phone FROM ll_delivery_order", String.class)).containsOnly("456"); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id")).isEqualTo(tasksBefore); + assertThat(jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id")).isEqualTo(arrivals); + assertThat(execution.loading(number).confirmedLoad()).isNull(); + assertThat(changed.dispatches()).allMatch(row -> "PENDING".equals(row.review().status())); + assertThat(changed.loadingReview().status()).isEqualTo("PENDING"); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("调度审核"); + approveDispatches(number); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("确认本车实际装载"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, + Long.parseLong(original.vehicleId()), Long.parseLong(original.driverId()))).isZero(); + var oldLoad = service.history(number, 1, 20).records().stream().filter(row -> row.action().equals("CONFIRM_LOAD")).findFirst().orElseThrow(); + assertThat(oldLoad.snapshot().trip().vehicleId()).isEqualTo(original.vehicleId()); + assertThat(oldLoad.snapshot().confirmedLoad()).isNotNull(); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "old-before-reassign", null))).hasMessageContaining("已被修改"); + var reconfirmed = confirm(number); + assertThat(execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(reconfirmed.recordVersion(), "after-reassign", null)).shippedPackages()).isEqualTo(20); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); + } + + @Test void partialPickingAllowsResourceAndTimeChangesButKeepsRouteAndBindingsFrozen() { + String number = readyTrip(); var original = service.detail(number).trip(); + jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=5 WHERE id=12"); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING',completed_at=NULL WHERE dispatch_order_id=12"); + jdbc.update("UPDATE ll_dispatch_pda_pick SET packages=5 WHERE id=12"); + var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id"); + var changed = service.assign(number, "1", new FleetTripModels.Assign(original.recordVersion(), vehicle(true), driver(true), "发车前更换资源")).trip(); + var delayed = service.updatePlan(number, "1", new FleetTripModels.Plan(changed.recordVersion(), departure.plusSeconds(60), end.plusSeconds(60), stops(), null, "延后任务时间")).trip(); + assertThat(delayed.expectedDriverEndAt()).isEqualTo(end.plusSeconds(60)); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); + assertThat(jdbc.queryForObject("SELECT status FROM ll_dispatch_pda_task WHERE dispatch_order_id=12", String.class)).isEqualTo("PICKING"); + assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(delayed.recordVersion(), "DP-12", "移走已拣单"))).hasMessageContaining("已开始"); + var differentStops = List.of(stops().get(0), new FleetTripModels.Stop("另一目的地", "新地址", "FBA:LAX9")); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(delayed.recordVersion(), departure, end, differentStops, null, "更改路线"))).hasMessageContaining("已开始"); + assertThat(service.detail(number).trip().stops()).isEqualTo(stops()); + assertThat(service.detail(number).dispatches()).hasSize(2); + } + + @Test void replacingConfirmedLoadWithInsufficientCapacityOrExpiredTaskCoverageChangesNothing() { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + long small = vehicle(true); var current = vehicles.findById(small).orElseThrow(); + vehicleService.update(small, "1", new VehicleModels.Update(0L, current.plateNumber(), current.model(), current.fuelConsumptionPer100Km(), current.mileageKm(), null, null, + current.region(), current.ownership(), null, 30, new BigDecimal("50"), new BigDecimal("90"))); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), small, driver(true), "容量不足换车"))).hasMessageContaining("已确认装载"); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(loaded.recordVersion(), departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期跨过证照到期"))).hasMessageContaining("预计任务结束前到期"); + assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId()); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion()); + assertThat(execution.loading(number).confirmedLoad()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, small)).isZero(); + } + + @Test void reassignmentHistoryFailureRestoresOriginalClaimsSnapshotsAndLoad() { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + long replacement = vehicle(true), replacementDriver = driver(true); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_reassignment CHECK(operator_id <> 'reject-operator')"); + assertThatThrownBy(() -> service.assign(number, "reject-operator", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "更换车辆司机"))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId()); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion()); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(original.vehiclePlate()); + assertThat(execution.loading(number).confirmedLoad()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, replacement, replacementDriver)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + } + + @Test void concurrentReassignmentAndDepartureCannotMixOldAndNewResources() throws Exception { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456"); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + try { + var assign = executor.submit(() -> { + start.await(5, TimeUnit.SECONDS); + try { + service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "临时改派")); + return true; + } catch (org.springframework.web.server.ResponseStatusException stale) { + assertThat(stale.getStatusCode().value()).isEqualTo(409); return false; + } + }); + var depart = executor.submit(() -> { + start.await(5, TimeUnit.SECONDS); + try { + execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "race-with-reassign", null)); + return true; + } catch (org.springframework.web.server.ResponseStatusException stale) { + assertThat(stale.getStatusCode().value()).isEqualTo(409); return false; + } + }); + start.countDown(); + boolean assigned = assign.get(15, TimeUnit.SECONDS), departed = depart.get(15, TimeUnit.SECONDS); + assertThat(List.of(assigned, departed)).containsExactlyInAnyOrder(true, false); + var result = service.detail(number).trip(); + assertThat(result.vehicleId()).isEqualTo(assigned ? Long.toString(replacement) : original.vehicleId()); + assertThat(result.status()).isEqualTo(departed ? "IN_TRANSIT" : "PLANNED"); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(result.vehiclePlate()); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(result.vehiclePlate()); + assertThat(jdbc.queryForObject("SELECT SUM(shipped_package_count) FROM ll_dispatch_order", Integer.class)).isEqualTo(departed ? 20 : 0); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, + Long.parseLong(result.vehicleId()))).isEqualTo(1); + if (assigned) assertThat(execution.loading(number).confirmedLoad()).isNull(); + } finally { executor.shutdownNow(); } + } + + @Test void physicalLoadingRejectsDuplicateMissingOverweightAndWrongDestinationMeasurements() { + String number = readyTrip(); var loading = execution.loading(number); var measurement = measurement(); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement, measurement), "核对"))) + .hasMessageContaining("重复计量"); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(), "核对"))) + .hasMessageContaining("每个实物托盘"); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), + List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("18001"), new BigDecimal("1"), 1)), "核对"))).hasMessageContaining("超过车辆"); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), + List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 2)), "核对"))).hasMessageContaining("卸货站"); + jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12"); + var mixed = execution.loading(number); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(mixed.recordVersion(), mixed.manifestHash(), List.of(measurement), "核对"))) + .hasMessageContaining("不同卸货目的地"); + } + + @Test void changedCargoAndUnreadyMemberBlockTheEntireVehicleBeforeAnyDepartureWrite() { + String number = readyTrip(); var confirmed = confirm(number); + jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12"); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("货物发生变化"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "changed-cargo", null))) + .hasMessageContaining("货物发生变化"); + jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='ONT8' WHERE dispatch_order_id=12"); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING' WHERE dispatch_order_id=12"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "unready-member", null))) + .hasMessageContaining("DP-12"); + assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART"); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + } + + @Test void secondCustomerFailureRollsBackFirstCustomerOutboundAndAllReplayRecords() { + String number = readyTrip(); var confirmed = confirm(number); + jdbc.execute("ALTER TABLE ll_dispatch_order ADD CONSTRAINT fail_second_depart CHECK(id <> 12 OR status <> 'DEPARTED')"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "rollback-whole-trip", null))) + .isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART"); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isZero(); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); + } + + @Test void dispatchDepartureRechecksDriverAndRejectsWrongWarehouseAndStaleVersion() { + String number = readyTrip(); var confirmed = confirm(number); + assertThatThrownBy(() -> execution.depart("93", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "wrong-warehouse", null))) + .hasMessageContaining("当前绑定仓"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(0L, "stale-version", null))) + .hasMessageContaining("已被修改"); + jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1))); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "expired-driver", null))) + .isInstanceOf(ResponseStatusException.class); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); + } + + @Test void simultaneousWholeTripDepartureReplaysOnceWithoutDoubleShipping() throws Exception { + String number = readyTrip(); var confirmed = confirm(number); + var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "concurrent-departure", null); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + Callable action = () -> { start.await(5, TimeUnit.SECONDS); return execution.depart("92", "DP-11", "1", request).replayed(); }; + try { + var a = executor.submit(action); var b = executor.submit(action); start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(false, true); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isEqualTo(2); + } finally { executor.shutdownNow(); } + } + + @Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() { + String number = readyTrip(); var confirmed = confirm(number); + long driverId = Long.parseLong(service.detail(number).trip().driverId()); + var current = drivers.detail(driverId, end); + var original = current.details(); + var domestic = new DriverModels.Draft(original.name(), original.phone(), original.ownership(), "DOMESTIC", + original.licenseNumber(), original.licenseType(), original.licenseIssuedOn(), original.licenseExpiresOn(), + original.licenseZone(), original.remark(), original.profile()); + var changed = drivers.update(driverId, "1", new DriverModels.Update(current.recordVersion(), domestic)); + assertThat(drivers.detail(driverId, end).qualification().canDispatch()).isTrue(); + + var gate = execution.gate("92", "DP-11"); + assertThat(gate.canDepart()).isFalse(); + assertThat(gate.blockReason()).contains("海外司机"); + var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "driver-region-check", null); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", request)).hasMessageContaining("海外司机"); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(confirmed.recordVersion()); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); + + drivers.update(driverId, "1", new DriverModels.Update(changed.recordVersion(), original)); + assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); + assertThat(execution.depart("92", "DP-11", "1", request).status()).isEqualTo("IN_TRANSIT"); + } + + @Test void cargoCompletionDoesNotReleaseResourcesAndEachTaskEndsIndependently() { + String number = departedTrip(); markDeliveriesSigned(); + var before = service.detail(number).trip(); + var request = new FleetCompletionModels.Complete(before.recordVersion(), "complete-cargo", "全部货物已处理完"); + var completed = completion.complete(number, "1", request).trip(); + assertThat(completed.status()).isEqualTo("COMPLETED"); + assertThat(completed.completedAt()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThat(completion.complete(number, "1", request).trip().recordVersion()).isEqualTo(completed.recordVersion()); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().completion().outcomes()).hasSize(2); + var vehicleEnded = completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "release-vehicle", "车辆检修")).trip(); + assertThat(vehicleEnded.vehicleRelease().vehicleStatus()).isEqualTo("MAINTENANCE"); + assertThat(vehicleEnded.driverRelease()).isNull(); + assertThat(vehicles.findById(Long.parseLong(vehicleEnded.vehicleId())).orElseThrow().status()).isEqualTo("MAINTENANCE"); + assertThat(jdbc.queryForObject("SELECT resource_kind FROM ll_fleet_resource_assignment", String.class)).isEqualTo("DRIVER"); + jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1))); + var driverEnded = completion.release(number, "1", new FleetCompletionModels.Release(vehicleEnded.recordVersion(), "DRIVER", Instant.now(), "交接点", null, "release-driver", "返程任务结束")).trip(); + assertThat(driverEnded.driverRelease()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + } + + @Test void incompleteOrCancelledDeliveryDoesNotCountAsCargoCompletion() { + String number = departedTrip(); + assertThat(completion.inspection(number).canComplete()).isFalse(); + markDeliveriesSigned(); + jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012"); + assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12"); + assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); + jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9 WHERE id=2012"); + assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + } + + @Test void returnedStatusRequiresImmutableReturnCompletionEvidence() { + String number = departedTrip(); markDeliveriesSigned(); + jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL WHERE id=2012"); + assertThat(completion.inspection(number).canComplete()).isFalse(); + LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); + jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt); + jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1)); + assertThat(completion.inspection(number).canComplete()).isTrue(); + var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip(); + assertThat(completed.completedAt()).isEqualTo(returnedAt.atZone(ZoneId.systemDefault()).toInstant()); + } + + @Test void laterReturnRequestDoesNotReopenAlreadySignedOriginalTransport() { + String number = departedTrip(); markDeliveriesSigned(); + var signedAt = jdbc.queryForObject("SELECT signed_at FROM ll_delivery_order WHERE id=2012", LocalDateTime.class); + jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING' WHERE id=2012"); + var inspection = completion.inspection(number); + assertThat(inspection.canComplete()).isTrue(); + assertThat(inspection.outcomes().get(1).status()).isEqualTo("DELIVERED"); + assertThat(inspection.outcomes().get(1).terminalAt()).isEqualTo(signedAt.atZone(ZoneId.systemDefault()).toInstant()); + completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "original-transport-complete", "原运输签收已完成")); + assertThat(jdbc.queryForObject("SELECT status FROM ll_delivery_order WHERE id=2012", String.class)).isEqualTo("RETURN_PENDING"); + } + + @Test void releaseChecksCompletionTimeAndRollsBackVehicleStatusAndClaimWhenHistoryFails() { + String number = departedTrip(); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "premature-release", "结束"))) + .hasMessageContaining("先核对全部货物"); + markDeliveriesSigned(); + var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "ready-completion", "核对")).trip(); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", completed.completedAt().minusSeconds(1), "仓库", "IN_SERVICE", "early-release", "结束"))) + .hasMessageContaining("实际任务结束时间"); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "DRIVER", Instant.now().plusSeconds(60), "仓库", null, "future-release", "结束"))) + .hasMessageContaining("实际任务结束时间"); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT fail_release CHECK(action <> 'RELEASE_VEHICLE')"); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "failed-release", "维修"))) + .isInstanceOf(RuntimeException.class); + assertThat(vehicles.findById(Long.parseLong(completed.vehicleId())).orElseThrow().status()).isEqualTo("IN_SERVICE"); + assertThat(service.detail(number).trip().vehicleRelease()).isNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + } + + @Test void oldReleaseRetryCannotReleaseAResourceAssignedToTheNextTrip() { + String number = departedTrip(); markDeliveriesSigned(); + var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "completion-replay", "核对")).trip(); + var release = new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "下一起运点", "IN_SERVICE", "vehicle-release-replay", "结束本次车辆任务"); + completion.release(number, "1", release); + String next = trip(); service.assign(next, "1", new FleetTripModels.Assign(0L, Long.parseLong(completed.vehicleId()), driver(true), "下一任务")); + completion.release(number, "1", release); + assertThat(jdbc.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=?", String.class, Long.parseLong(completed.vehicleId()))).isEqualTo(next); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='RELEASE_VEHICLE'", Integer.class)).isEqualTo(1); + } + + private String departedTrip() { + String number = readyTrip(); var loading = confirm(number); + execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-for-completion", null)); + return number; + } + private void markDeliveriesSigned() { + jdbc.update("UPDATE ll_outbound_order SET package_count=10"); + jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); + } + + private String readyTrip() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); + approveDispatches(number); + for (long id : List.of(11L, 12L)) { + String customer = id == 11 ? "CLIENT-A" : "CLIENT-B"; + jdbc.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=10 WHERE id=?", id); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='COMPLETED' WHERE dispatch_order_id=?", id); + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')", id + 3000, id, customer); + jdbc.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages,product_quantity,idempotency_key,picked_at) VALUES(?,?,?,?,?,10,0,?,CURRENT_TIMESTAMP)", id, customer, id, id + 3000, id + 4000, "pick-" + id); + } + return number; + } + private FleetExecutionModels.Loading confirm(String number) { + var loading = execution.loading(number); + var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "实际装载核对")); + execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "APPROVED", null)); + return execution.loading(number); + } + private void approveDispatches(String number) { + for (var binding : service.detail(number).dispatches()) service.reviewDispatch(number, binding.dispatchNo(), "1", + new FleetTripModels.Review(service.detail(number).trip().recordVersion(), "APPROVED", null)); + } + private static FleetExecutionModels.Measurement measurement() { return new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 1); } + + private boolean bindAttempt(CountDownLatch start, String number) throws Exception { + start.await(5, TimeUnit.SECONDS); + try { service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "并发关联")); return true; } + catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; } + } + private String assignedTrip() { + String number = trip(); service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); return number; + } + private void dispatch(long id, String customer, String warehouse) { + jdbc.update("INSERT INTO ll_dispatch_order(id, customer_id, dispatch_no, overseas_warehouse_id, package_count) VALUES(?,?,?,?,10)", id, customer, "DP-" + id, warehouse); + jdbc.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,task_no,status,warehouse_id,pushed_at) VALUES(?,?,?,?,'PDA_PENDING',?,CURRENT_TIMESTAMP)", + id, customer, id, "TASK-" + id, warehouse); + } + private void delivery(long dispatchId, String customer) { + jdbc.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,idempotency_key) VALUES(?,?,?,?,?)", + dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId); + jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)", + dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0)); + } + + private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception { + start.await(5, TimeUnit.SECONDS); + try { service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "并发安排")); return true; } + catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; } + } + private String trip() { return service.create("1", createRequest(UUID.randomUUID().toString())).trip().tripNo(); } + private FleetTripModels.Create createRequest(String key) { return new FleetTripModels.Create(92L, departure, end, stops(), null, key); } + private static List stops() { return List.of(new FleetTripModels.Stop("起运仓", "提货地址", null), new FleetTripModels.Stop("派送仓", "送货地址", "FBA:ONT8")); } + private long vehicle(boolean capacity) { + return vehicles.create(new VehicleRepository.NewVehicle("TEST-" + UUID.randomUUID(), "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), capacity ? 30 : null, capacity ? new BigDecimal("18000") : null, capacity ? new BigDecimal("90") : null)); + } + private long driver(boolean approve) { return driver(approve, "司机", "123"); } + private long driver(boolean approve, String name, String phone) { + var draft = new DriverModels.Draft(name, phone, "OWNED", "OVERSEAS", "LIC-" + UUID.randomUUID(), "Class A", null, LocalDate.now(ZoneOffset.UTC).plusDays(30), "UTC", null, null); + var driver = drivers.create("1", draft); long id = Long.parseLong(driver.id()); + if (approve) { + drivers.uploadLicense(id, 0, "1", new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes())); + drivers.review(id, "1", new DriverModels.Review(1L, "APPROVED", null)); + } + return id; + } + private T transactional(T target, Class type) { + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource())); + return type.cast(proxy.getProxy()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java index 91fffa32d..7a63467e7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationServi import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,7 +24,7 @@ class VehicleApplicationServiceTest { @BeforeEach void setUp() { repository = mock(VehicleRepository.class); - service = new VehicleApplicationService(repository); + service = new VehicleApplicationService(repository, mock(VehicleAssignmentGuard.class)); } @Test @@ -52,13 +53,30 @@ class VehicleApplicationServiceTest { failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); } + @Test void rejectsPartialOrNonPositiveCapacityWithoutGuessingFromModel() { + var partial = new VehicleModels.Create("ABC-001", "大车30托", new BigDecimal("12.50"), 1000L, + null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, 30, null, new BigDecimal("90")); + assertThatThrownBy(() -> service.create("9", partial)).hasMessageContaining("同时填写"); + var zero = new VehicleModels.Create("ABC-001", "大车30托", new BigDecimal("12.50"), 1000L, + null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, 30, BigDecimal.ZERO, new BigDecimal("90")); + assertThatThrownBy(() -> service.create("9", zero)).hasMessageContaining("大于零"); + org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()).create(org.mockito.ArgumentMatchers.any()); + } + + @Test void exportUsesPlateAsBusinessIdentifierAndIncludesAllCapacityDimensions() { + when(repository.list(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(vehicle(3, 1000))); + String csv = new String(service.exportCsv(new VehicleModels.Query(1, 20, null, null, null, null)), java.nio.charset.StandardCharsets.UTF_8); + assertThat(csv).contains("最大托位,最大载重(kg),可用容积(m³)").contains("\"30\",\"18000\",\"90\"") + .doesNotContain("车辆ID"); + } + private static VehicleModels.Update update(long version, long mileage) { return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", null); + "Driver", "123", "OVERSEAS", "OWNED", null, 30, new BigDecimal("18000"), new BigDecimal("90")); } private static VehicleSnapshot vehicle(long version, long mileage) { return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null); + "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null, 30, new BigDecimal("18000"), new BigDecimal("90")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index 469322c13..59fefbe2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -60,6 +60,7 @@ class PdaOutboundDispatchContractTest { DispatchPdaController dispatch = new DispatchPdaController(); OutboundPdaController outbound = new OutboundPdaController(); ReflectionTestUtils.setField(dispatch, "service", dispatchService); + ReflectionTestUtils.setField(dispatch, "trips", mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class)); ReflectionTestUtils.setField(outbound, "service", outboundService); AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index e7de9f255..ae25afa78 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -64,7 +64,8 @@ class OutboxLeaseDatabaseTest { var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); - var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper, + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class)); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java index cf0f50759..479887c13 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -23,7 +23,8 @@ class UnloadCompletedOutboxRepositoryTest { void feeClaimAlsoRechecksRetryWindow() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), LastlegEventOutbox.class); LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); - var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper, + mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class)); ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java new file mode 100644 index 000000000..b17cd4223 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.transfer.fleet; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.transfer.dto.*; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; +class FleetIncomingAdapterTest { + @Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() { + var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); + var detail=mock(WarehouseTransferResponses.Detail.class); + when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("WT-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.fbaWarehouseCode()).thenReturn("ONT8"); + when(detail.status()).thenReturn("WAIT_PDA_RECEIVING");when(detail.pallets()).thenReturn(List.of()); + when(service.createAndSubmit(eq("9"),eq("1"),any())).thenReturn(detail);when(service.review(eq("9"),eq("WT-1"),eq("1"),any())).thenReturn(detail); + var cargo=new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,2,new BigDecimal("100"),new BigDecimal("1"),"PALLET",new BigDecimal("50"),"USD",null,null,null); + var result=adapter.create("1",new FleetBookingIncomingGateway.Incoming("9","92","起运仓","FB-1",cargo,LocalDateTime.of(2030,1,1,12,0),null,null,null,null,null)); + assertThat(result.receivedPackages()).isZero();assertThat(result.pallets()).isEmpty();assertThat(result.status()).isEqualTo("WAIT_PDA_RECEIVING"); + var captor=ArgumentCaptor.forClass(WarehouseTransferRequests.Create.class);verify(service).createAndSubmit(eq("9"),eq("1"),captor.capture()); + assertThat(captor.getValue().inboundCode()).isNull();assertThat(captor.getValue().sourceLastlegOrderIds()).isEmpty(); + assertThat(captor.getValue().deliveryMethod()).isEqualTo("CUSTOMER_DELIVERY");assertThat(captor.getValue().operatingWarehouseId()).isEqualTo("92"); + assertThat(captor.getValue().idempotencyKey()).isEqualTo("FLEET:FB-1:INCOMING"); + verify(service,never()).confirmMeasurement(any(),any(),any(),any()); + } + @Test void privateAddressCannotSilentlyChangeTheBookedDestination() { + var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); + var cargo=new FleetBookingModels.Cargo(FleetDispatchGateway.destinationKey("PRIVATE",null,null,"Road 1","90001"),null,1,1,BigDecimal.ONE,BigDecimal.ONE,"ORDER",BigDecimal.TEN,"USD",null,null,null); + assertThatThrownBy(()->adapter.create("1",new FleetBookingIncomingGateway.Incoming("9","92","仓","FB-1",cargo,LocalDateTime.now(),null,"Road 2","90001","A","123"))).hasMessageContaining("卸货站不一致"); + verifyNoInteractions(service); + } +} 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 b37fa3273..a2dde1a0e 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -1,8 +1,12 @@ { "schemaVersion": 1, + "forbidPackageCycles": true, "description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", "allowedSliceEdges": [ + "fleet->shared-technical", + "fleet->overseas-master-data", "dispatch-delivery->dropship", + "dispatch-delivery->fleet", "dispatch-delivery->inbound-pda", "dispatch-delivery->inventory", "dispatch-delivery->relabel-transfer-discard", @@ -28,7 +32,6 @@ "relabel-transfer-discard->inventory", "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", - "security-projection->shared-technical", "tailleg-handoff->dispatch-delivery", "tailleg-handoff->dropship", "tailleg-handoff->inbound-pda", @@ -42,14 +45,152 @@ "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", - "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java", - "com.baiyue.lastleg.security.controller.LastlegSecurityController": "src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java", - "com.baiyue.lastleg.security.dto.LastlegSecurityDtos": "src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java", - "com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java", - "com.baiyue.lastleg.security.service.LastlegSecurityProjectionService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java", - "com.baiyue.lastleg.security.service.LastlegSecurityValidationService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java", - "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity": "src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java" + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" }, + "forbiddenSlices": ["security-projection"], + "forbiddenTypePrefixes": [ + "com.baiyue.lastleg.security.controller.LastlegSecurity", + "com.baiyue.lastleg.security.dto.LastlegSecurity", + "com.baiyue.lastleg.security.service.LastlegSecurity", + "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" + ], + "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", + "target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway", + "reason": "转运适配器实现外接来货端口,仅传登记与实测证据;车队不读取转运Mapper或库存表。" + }, + { + "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", + "target": "com.baiyue.lastleg.fleet.trip.FleetDispatchGateway", + "reason": "仅复用端口定义的稳定卸货目的地匹配键,禁止放宽整个转运切片对车队实现的引用。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.dto.RelabelRequests", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.dto.RelabelResponses", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.service.RelabelApplicationService", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.discard.pda.controller.PdaCargoDiscardController", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "PDA废弃入口复用统一Token身份与绑定仓解析,不另建本地安全投影。" + }, + { + "source": "com.baiyue.lastleg.discard.pda.service.PdaCargoDiscardService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "PDA废弃作业使用同一已验证身份范围。" + }, + { + "source": "com.baiyue.lastleg.transfer.controller.PdaWarehouseTransferController", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "转运PDA入口复用同一绑定仓身份解析。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlan", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", + "target": "com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate", + "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" + }, + { + "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", + "target": "com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper", + "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.controller.PdaReturnReceiptController", + "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", + "reason": "退货签收照片复用当前业务文件网关。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.entity.RelabelApplication", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.entity.RelabelItem", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.mapper.RelabelItemMapper", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + } +], "applicationDependencyRule": { "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", "allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application", @@ -62,13 +203,13 @@ ] }, "sliceSpecificRules": { - "security-projection": { - "allowedOutgoing": ["shared-technical"], - "allowedIncoming": [] + "fleet": { + "allowedOutgoing": ["overseas-master-data", "shared-technical"], + "allowedIncoming": ["dispatch-delivery"] }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], - "allowedIncoming": ["procurement-trucking-looseboard"] + "allowedIncoming": ["procurement-trucking-looseboard", "fleet"] } } } diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 7e0ece991..ab7582ab3 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 136]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 145]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|136) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|145) AS initialized_tables;$/) } }) @@ -56,7 +56,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 370, iam_role_permission: 551, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 392, iam_role_permission: 573, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -81,6 +81,14 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') assert.ok(admin.has('lastleg:cargo-discard:approve'), 'existing discard review endpoint must be grantable to platform administrator') + for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { + assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) + } + for (const action of ['list', 'create', 'edit', 'assign', 'cancel']) { + assert.ok(admin.has(`platform:fleet:trip:${action}`)) + } + assert.ok(!permissions.some(row => row.permission_code === 'platform:order:complete'), + '订单完成必须由可信履约回写产生,不能恢复人工完成权限') for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-business:purchase:list', 'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review', diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 9c527c0ea..bb49494a9 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,18 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '运维图片上传无远端幂等键,调用方保留请求结果;元数据与下载按平台/记录复合归属核对,禁止客户端自动重试'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java', + 'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', + '外接登记读取权威有效客户与下单资格,内部服务令牌保护;不依据采购历史或创建客户主档'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '司机仓绑定候选只读权威仓库目录,使用内部服务令牌,不传用户JWT或修改仓库主档'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java', 'lastleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用及资金指令按eventId/hash直投对应Finance内部入口,严格DeliveryAck'), http('common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', @@ -169,6 +181,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '内部服务令牌上传驾驶证,无上传幂等键;读取按平台公共作用域、司机业务键及文件ID核对类型/大小/归属,不自动重放上传'), http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'), http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index d4d269793..0a673d213 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -32,6 +32,8 @@ const expectedOnlineEdges = [ 'iam-service->auth-service', 'iam-service->warehouse-service', 'lastleg-service->file-service', + 'lastleg-service->finance-service', + 'lastleg-service->iam-service', 'lastleg-service->print-service', 'lastleg-service->warehouse-overseas-service', 'lastleg-service->warehouse-service', @@ -100,7 +102,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, 63) + assert.equal(reviewed.length, 66) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -116,7 +118,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, 22) + assert.equal(reviewed.length, 24) assert.deepEqual(discovered.sort(), reviewed) }) @@ -206,7 +208,11 @@ test('unified authority checks remain infrastructure edges and direct events rem assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway->auth-service')) assert.ok(graphEdges('INFRASTRUCTURE').includes('common-security->auth-service')) assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) - assert.ok(!graphEdges('REQUEST').includes('lastleg-service->finance-service')) + const financeReads = SYNCHRONOUS_CLIENTS.filter(client => client.source === 'lastleg-service' + && client.target === 'finance-service' && client.contexts.includes('REQUEST')) + assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]), [[ + 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ' + ]]) assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) @@ -264,9 +270,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h 'file-service->iam-service->auth-service', 'file-service->iam-service->warehouse-service' ] }, - 'lastleg-service': { hops: 3, paths: [ - 'lastleg-service->file-service->iam-service->auth-service', - 'lastleg-service->file-service->iam-service->warehouse-service' + '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' ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 1, paths: [ @@ -280,6 +286,16 @@ test('known user entries expose every longest simple REQUEST path over the two-h } }) +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') + assert.equal([...client.matchAll(/@(?:Get|Post|Put|Delete|Patch)Mapping/g)].length, 1) + assert.ok(client.includes('/internal/finance/v1/delivery-fee-coverage/sources')) + assert.ok(service.includes('@Transactional(readOnly = true)')) + assert.ok(!/\.client\.|Feign|RestClient|WebClient|HttpClient|jdbc\.update|\.execute\(/.test(service)) + assert.ok(service.includes("f.customer_id=:customerId AND f.inbound_id IN (:inboundIds)")) +}) + test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { for (const profile of ['test']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') -- 2.54.0 From bde1cd532ed22a7e9894affb231638a7d0f7744c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 05:25:30 +0800 Subject: [PATCH 075/176] test(order): add isolated dependency failure and recovery verification --- scripts/README.md | 2 + ...rs-lifecycle-idempotency-contract.test.mjs | 49 +- scripts/admin-test-env-modules/orders.mjs | 76 ++- ...order-submit-dependency-fault-executor.mjs | 251 ++++++++++ ...-submit-dependency-fault-executor.test.mjs | 209 ++++++++ .../order-submit-dependency-fault-proxy.py | 176 +++++++ ...der-submit-dependency-fault-proxy.test.mjs | 69 +++ .../order-submit-dependency-fault-runtime.py | 445 ++++++++++++++++++ 8 files changed, 1272 insertions(+), 5 deletions(-) create mode 100644 scripts/order-submit-dependency-fault-executor.mjs create mode 100644 scripts/order-submit-dependency-fault-executor.test.mjs create mode 100644 scripts/order-submit-dependency-fault-proxy.py create mode 100644 scripts/order-submit-dependency-fault-proxy.test.mjs create mode 100644 scripts/order-submit-dependency-fault-runtime.py diff --git a/scripts/README.md b/scripts/README.md index b3bb97ad6..f6e535b2f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,8 @@ # 后端脚本管理与全量 HTTP 验收索引 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 + +`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos 配置;`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 diff --git a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs index 55210f33a..2de2df749 100644 --- a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs @@ -2,7 +2,54 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit, verifyPlatformSubmitCancel } from './orders.mjs' +import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit, verifyPlatformSubmitCancel, + buildOrderDependencyFaultPlan, assertOrderDependencyFaultObservation } from './orders.mjs' + +function dependencyFixture() { + const detail = id => ({order:{id,orderNo:`FL${id}`,tenantId:'20',status:'DRAFT',version:4}, + cargoes:[{skuNo:'current'}],parties:[],internalNotes:[],statusHistory:[]}) + return {marker:'CURRENT-RUN',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')} +} +function dependencyObservation(plan, fixture, dependency, role) { + const phase=plan.phases.find(row=>row.dependency===dependency),command=plan.commands[role] + const snapshot={detail:structuredClone(fixture[role]),task:null,inbounds:[],inboundPageComplete:true} + return {request:{method:command.method,path:command.path,body:structuredClone(command.body),headers:{...command.headers,'X-Request-Id':`${dependency}-${role}`}}, + response:{http:200,body:{code:'S0503',message:phase.expectedMessages[0]}},before:snapshot,after:structuredClone(snapshot), + proxyRecords:[{requestId:`${dependency}-${role}`,method:'GET',path:phase.targetPath,matched:true,responseStatus:503,forwarded:false,internalTokenPresent:true}]} +} +test('dependency plan binds two new-customer drafts and only named Order upstream properties; it performs no runtime actions',()=>{ + const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture) + assert.equal(plan.runtimeChangesExecuted,false) + assert.deepEqual(plan.phases.map(row=>row.urlProperty),['baiyue.clients.iam.url','baiyue.clients.finance.url']) + assert.equal(plan.commands.merchant.body.expectedVersion,4) + fixture.merchant.order.version=9 + assert.equal(plan.commands.merchant.body.expectedVersion,4) + assert.ok(Object.isFrozen(plan.commands.merchant.body)) + for(const change of [f=>f.customerId='0',f=>f.registeredCustomerId='21',f=>f.platform.order.id='101',f=>f.merchant.order.status='SUBMITTED',f=>delete f.merchant.cargoes]) { + const value=dependencyFixture();change(value);assert.throws(()=>buildOrderDependencyFaultPlan(value)) + } +}) +test('all four dependency-role observations require matching authenticated 503 injection and actual Order S0503 with zero drift',()=>{ + const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture) + for(const dependency of ['IAM','FINANCE'])for(const role of ['merchant','platform']) { + const evidence=dependencyObservation(plan,fixture,dependency,role) + assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,evidence).verified,true) + const wrapped=structuredClone(evidence) + wrapped.response.body.message=plan.phases.find(row=>row.dependency===dependency).expectedMessages[1] + assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,wrapped).verified,true) + for(const mutate of [ + value=>value.response.http=503,value=>value.response.body.code='A0403',value=>value.response.body.message='资格未审核', + value=>value.request.body.expectedVersion=5,value=>value.request.headers['Idempotency-Key']='new-key', + value=>value.proxyRecords=[],value=>value.proxyRecords[0].internalTokenPresent=false,value=>value.proxyRecords[0].forwarded=true, + value=>value.proxyRecords[0].path+='/other',value=>value.proxyRecords[0].requestId='other-request', + value=>value.proxyRecords.push({...value.proxyRecords[0],path:'/internal/iam/tenants/21/order-eligibility'}), + value=>value.after.detail.order.version++,value=>value.after.detail.statusHistory.push({id:'unexpected'}), + value=>value.after.task={orderId:plan.commands[role].orderId,tenantId:'20',id:'unexpected'}, + value=>value.after.inbounds.push({tenantId:'20',orderNo:plan.commands[role].orderNo,id:'unexpected'}), + value=>value.after.inboundPageComplete=false, + ]) { const changed=structuredClone(evidence);mutate(changed);assert.throws(()=>assertOrderDependencyFaultObservation(plan,dependency,role,changed)) } + } +}) function platformLifecycleFixture(fault = '', faultAction = 'submit') { const drafts = new Map(), memo = new Map(), calls = [] diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index c85d77c77..d43436d7f 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -2,8 +2,6 @@ import { createHash } from 'node:crypto' const MODULE = 'orders' const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const BOXES = [ { boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 }, { boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 }, @@ -243,6 +241,69 @@ export function orderPersistenceFacts(detail) { statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [], }) } +// Read-only planning and evidence validation only. These exports never configure services or send requests. +export function buildOrderDependencyFaultPlan(fixture) { + const customerId = id(fixture?.customerId) + if (!/^[1-9]\d*$/.test(customerId) || customerId !== id(fixture?.registeredCustomerId) + || !/^[A-Za-z0-9_-]{1,80}$/.test(fixture?.marker || '')) throw new Error('Dependency fault fixture must belong to this run registered customer') + const commands = {} + for (const role of ['merchant','platform']) { + const detail = fixture[role] + if (id(detail?.order?.tenantId) !== customerId || !/^[1-9]\d*$/.test(id(detail?.order?.id)) + || detail.order.status !== 'DRAFT' || !detail.order.orderNo + || !['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail[field]))) { + throw new Error(`Incomplete owned ${role} DRAFT evidence`) + } + const command = freezeOrderCommand(detail, `dep-${role}-${fixture.marker}`) + commands[role] = { method:'POST', path:`/api/${role}/v1/orders/first-leg/${detail.order.id}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, + ...command, orderId:id(detail.order.id), orderNo:detail.order.orderNo, + initialOrderSha256:createHash('sha256').update(orderPersistenceFacts(detail)).digest('hex') } + } + if (commands.merchant.orderId === commands.platform.orderId) throw new Error('Use two independent drafts for same-key recovery') + return { marker:fixture.marker, customerId, runtimeChangesExecuted:false, commands, + phases:[ + { dependency:'IAM', urlProperty:'baiyue.clients.iam.url', targetMethod:'GET', targetPath:`/internal/iam/tenants/${customerId}/order-eligibility`, + faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['IAM eligibility service is unavailable','IAM 下单资格响应无效'] }, + { dependency:'FINANCE', urlProperty:'baiyue.clients.finance.url', targetMethod:'GET', targetPath:`/internal/finance/v1/wallet/accounts/${customerId}`, + faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['Finance account service is unavailable','Finance account response is invalid'] }, + ], + recovery:'Restore original Order image/config/network identity; submit each original frozen command once, then replay it; require one submission history and no duplicate Firstleg facts.' } +} + +export function assertOrderDependencyFaultObservation(plan, dependency, role, observation) { + const phase = plan?.phases?.find(item => item.dependency === dependency), command = plan?.commands?.[role] + const require = (ok, message) => { if (!ok) throw new Error(`Dependency fault evidence: ${message}`) } + require(Boolean(phase && command), 'unknown dependency or role') + const request = observation?.request, response = observation?.response + require(request?.method === command.method && request.path === command.path + && JSON.stringify(request.body) === JSON.stringify(command.body) + && request.headers?.['Idempotency-Key'] === command.headers['Idempotency-Key'], 'original frozen request/version/key changed') + const requestId = request.headers?.['X-Request-Id'] + require(typeof requestId === 'string' && requestId.length > 0, 'missing correlation ID') + require(response?.http === phase.expectedHttp && String(response.body?.code) === phase.expectedCode + && phase.expectedMessages.includes(response.body.message), 'must be the real Order dependency failure, not auth/version/business validation') + require(Array.isArray(observation.proxyRecords) && observation.proxyRecords.some(row => + row.requestId === requestId && row.method === phase.targetMethod && row.path === phase.targetPath + && row.matched === true && row.responseStatus === phase.faultHttp && row.forwarded === false + && row.internalTokenPresent === true), 'missing exact authenticated upstream fault hit') + require(observation.proxyRecords.filter(row => row.matched === true).every(row => row.method === phase.targetMethod + && row.path === phase.targetPath && row.responseStatus === phase.faultHttp && row.forwarded === false), 'fault escaped selected customer/path') + for (const label of ['before','after']) { + const snapshot = observation[label], detail = snapshot?.detail + require(snapshot?.inboundPageComplete === true && Array.isArray(snapshot.inbounds) + && ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail?.[field])) + && id(detail?.order?.id) === command.orderId && id(detail.order.tenantId) === plan.customerId + && detail.order.status === 'DRAFT', `${label} incomplete/foreign order or Firstleg evidence`) + require(snapshot.task === null || snapshot.task && id(snapshot.task.orderId) === command.orderId + && id(snapshot.task.tenantId) === plan.customerId, `${label} task source mismatch`) + require(snapshot.inbounds.every(row => id(row.tenantId) === plan.customerId && row.orderNo === command.orderNo), `${label} inbound source mismatch`) + } + require(createHash('sha256').update(orderPersistenceFacts(observation.before.detail)).digest('hex') === command.initialOrderSha256, + 'baseline differs from prepared draft') + require(JSON.stringify(observation.before) === JSON.stringify(observation.after), 'order/version/history/Firstleg facts changed') + return { dependency, role, orderId:command.orderId, verified:true, requestId, + snapshotSha256:createHash('sha256').update(JSON.stringify(observation.after)).digest('hex') } +} export function requestedOrderDestinationType(context) { const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' @@ -848,11 +909,18 @@ async function waitForInbound(context, tenantId, orderId) { } export async function run(context) { + // Resolve per invocation: this module is cached across serial fresh-customer runs. + const fixture = context.context?.merchantQualificationFixture + const configured = context.options?.merchantUsername || context.options?.merchantPassword + const credentials = fixture ? { username: fixture.username, password: fixture.password } + : configured ? { username: context.options.merchantUsername, password: context.options.merchantPassword } + : { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD } + if (!credentials.username || !credentials.password) throw new Error('Client 当前运行缺少完整登录凭据') const loginResponse = await context.request('POST', '/api/auth/login', { - loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: credentials.username, password: credentials.password, }, '') const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse, - `username=${MERCHANT_USERNAME}`) + 'current-run customer credentials') const merchantToken = login?.accessToken if (!merchantToken) throw new Error('Client 登录未返回 accessToken') diff --git a/scripts/order-submit-dependency-fault-executor.mjs b/scripts/order-submit-dependency-fault-executor.mjs new file mode 100644 index 000000000..442037037 --- /dev/null +++ b/scripts/order-submit-dependency-fault-executor.mjs @@ -0,0 +1,251 @@ +// Explicit TEST-only runtime controller. Importing this module performs no HTTP. +import { readFileSync } from 'node:fs' +import { open, mkdir } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { resolve, dirname } from 'node:path' +import { pathToFileURL } from 'node:url' +import { assertOrderDependencyFaultObservation, orderPersistenceFacts } from './admin-test-env-modules/orders.mjs' + +const runtime = readFileSync(new URL('./order-submit-dependency-fault-runtime.py', import.meta.url), 'utf8') +const proxySource = readFileSync(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url)).toString('base64') +const commonSource = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url)).toString('base64') +const encode = value => Buffer.from(value).toString('base64') +const sha = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const assert = (ok, message) => { if (!ok) throw Error(message) } +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) +const safeError = error => /^(?:[A-Z][A-Z_0-9]{1,100}|Dependency fault evidence: [A-Za-z /:-]+)$/.test(error?.message || '') + ? error.message : 'ORDER_DEPENDENCY_VERIFICATION_FAILED' + +export function scopeFor(plan) { + assert(/^[A-Za-z0-9_-]{1,80}$/.test(plan?.marker || '') && /^[1-9]\d{0,19}$/.test(plan?.customerId || ''), 'Invalid TEST plan scope') + for (const role of ['merchant', 'platform']) { + const command = plan.commands?.[role] + assert(command?.method === 'POST' && /^[1-9]\d{0,19}$/.test(command.orderId || '') + && command.path === `/api/${role}/v1/orders/first-leg/${command.orderId}/submit${role === 'platform' ? `?tenantId=${plan.customerId}` : ''}` + && Number.isSafeInteger(command.body?.expectedVersion) && command.body.expectedVersion >= 0 + && Object.keys(command.body).length === 1 && /^[A-Za-z0-9_-]{1,128}$/.test(command.headers?.['Idempotency-Key'] || '') + && Object.keys(command.headers).length === 1 + && /^[0-9a-f]{64}$/.test(command.initialOrderSha256 || ''), 'Invalid frozen submission command') + } + assert(plan.commands.merchant.orderId !== plan.commands.platform.orderId, 'Two distinct new drafts required') + assert(plan.runtimeChangesExecuted === false, 'Only a reviewed unexecuted plan is accepted') + assert(Array.isArray(plan.phases) && plan.phases.length === 2, 'Exact two dependency phases required') + for (const [dependency, property, path, messages] of [ + ['IAM','baiyue.clients.iam.url',`/internal/iam/tenants/${plan.customerId}/order-eligibility`,['IAM eligibility service is unavailable','IAM 下单资格响应无效']], + ['FINANCE','baiyue.clients.finance.url',`/internal/finance/v1/wallet/accounts/${plan.customerId}`,['Finance account service is unavailable','Finance account response is invalid']], + ]) { + const rows = plan.phases.filter(row => row.dependency === dependency) + assert(rows.length === 1 && rows[0].urlProperty === property && rows[0].targetPath === path && rows[0].targetMethod === 'GET' + && rows[0].faultHttp === 503 && rows[0].expectedHttp === 200 && rows[0].expectedCode === 'S0503' + && JSON.stringify(rows[0].expectedMessages) === JSON.stringify(messages), 'Dependency failure contract may not be weakened') + } + return { marker: plan.marker, customerId: plan.customerId, + orderIds: Object.fromEntries(['merchant', 'platform'].map(role => [role, plan.commands[role].orderId])), planSha256: sha(plan) } +} + +export function renderOrderFaultAction(action, scope, agent, extra = {}) { + assert(['preflight','prepare','activate','health','mode','status','records','restore','reconcile','verify-restored','finish'].includes(action), 'Unknown runtime action') + assert(/^[A-Za-z0-9_.-]{1,128}$/.test(agent || '') && /^[A-Za-z0-9_-]{1,80}$/.test(scope.marker), 'Invalid agent/scope') + if (['prepare','preflight'].includes(action)) { + const routing = extra.routing + assert(routing && Object.keys(routing).every(key => ['configurationReviewed','sourceSha256','upstreams','nacos'].includes(key)) + && routing.configurationReviewed === true && routing.nacos && Object.keys(routing.nacos).every(key => ['baseUrl','loginPath','configPath'].includes(key)) + && routing.upstreams && Object.keys(routing.upstreams).sort().join(',') === 'FINANCE,IAM', 'Reviewed routing schema only; do not embed credentials') + for (const address of [routing.nacos.baseUrl,...Object.values(routing.upstreams)]) { + const url = new URL(address) + assert(['http:','https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash + && ['', '/'].includes(url.pathname), 'Routing origins cannot contain credentials/path/query') + } + if (action === 'prepare') assert(/^sha256:[0-9a-f]{64}$/.test(extra.expectedImageId || '') + && /^[0-9a-f]{64}$/.test(routing.sourceSha256 || ''), 'Reviewed image and Nacos source fingerprint required') + } + const data = encode(JSON.stringify({ ...extra, action, scope, commonSource, ...(action === 'prepare' ? { proxySource } : {}) })) + const expression = value => '[' + encode(value).match(/.{1,4000}/g).map(part => `'${part}'`).join(',') + "].join('')" + const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${expression(runtime)}),'UTF-8'),'${data}']).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${expression(groovy)}),'UTF-8'),c.channel))` +} + +export function createOrderFaultRuntime(env, scope, fetcher = globalThis.fetch) { + assert(env.CHONGCHONG_ENV === 'test', 'CHONGCHONG_ENV=test required') + assert(env.JENKINS_URL && env.JENKINS_USER && env.JENKINS_API_TOKEN && env.FBA_CORE_JENKINS_AGENT, 'Explicit TEST Jenkins credentials and agent required') + return async (action, extra = {}) => { + let response + try { + response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(55000), + headers: { Authorization: 'Basic ' + encode(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ script: renderOrderFaultAction(action, scope, env.FBA_CORE_JENKINS_AGENT, extra) }), + }) + } catch { throw Error(`RUNTIME_${action.toUpperCase().replaceAll('-', '_')}_OUTCOME_UNKNOWN_USE_STATUS`) } + assert(response.ok, `RUNTIME_HTTP_${response.status}_NO_AUTO_RETRY`) + const rows = (await response.text()).split(/\r?\n/).filter(row => row.startsWith('ORDER_DEPENDENCY_JSON=')) + assert(rows.length === 1, 'RUNTIME_RESPONSE_AMBIGUOUS_NO_AUTO_RETRY') + const result = JSON.parse(rows[0].slice('ORDER_DEPENDENCY_JSON='.length)) + assert(result?.ok === true, /^[A-Z_0-9]{1,90}$/.test(result?.error || '') ? result.error : 'RUNTIME_ACTION_FAILED') + return result.data + } +} + +export async function runOrderFaultWindow({ plan, routing, expectedImageId, action, snapshot, submit, recovery, wait = pause }) { + const scope = scopeFor(plan), report = { scope, success: false, observations: [], restoration: null } + let prepareAttempted = false, failure + const health = async () => { + for (let i = 0; i < 180; i++) { + const value = await action('health') + if (value.healthy === true) return + await wait(1000) + } + throw Error('ORDER_HEALTH_TIMEOUT') + } + try { + for (const role of ['merchant','platform']) { + const before = await snapshot(role) + assert(createHash('sha256').update(orderPersistenceFacts(before.detail)).digest('hex') === plan.commands[role].initialOrderSha256 + && before.detail.order.status === 'DRAFT' && String(before.detail.order.tenantId) === scope.customerId + && String(before.detail.order.id) === scope.orderIds[role] + && ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(before.detail[field])) + && before.inboundPageComplete === true, 'PREPARED_DRAFT_CHANGED') + } + prepareAttempted = true + report.prepared = await action('prepare', { routing, expectedImageId }) + for (let i = 0; i < 15; i++) { + if ((await action('status')).proxy?.mode === 'PASS') break + assert(i < 14, 'PROXY_STARTUP_NOT_READY'); await wait(1000) + } + await action('activate'); await health() + for (const dependency of ['IAM','FINANCE']) { + for (const role of ['merchant','platform']) { + const before = await snapshot(role), command = plan.commands[role] + const request = { method: command.method, path: command.path, body: structuredClone(command.body), + headers: { ...command.headers, 'X-Request-Id': `dep-${plan.marker}-${dependency}-${role}` } } + let response + await action('mode', { mode: dependency, ttl: 60 }) + try { response = await submit(role, request) } + finally { await action('mode', { mode: 'PASS' }) } + const after = await snapshot(role) + const proxyRecords = (await action('records')).records.filter(row => row.dependency === dependency) + report.observations.push(assertOrderDependencyFaultObservation(plan, dependency, role, { request, response, before, after, proxyRecords })) + } + } + } catch (error) { failure = error } + finally { + if (prepareAttempted) { + try { + await action('restore') // Never retry a mutation after an ambiguous outcome. + await health() + report.restoredRuntime = await action('verify-restored') + assert(report.restoredRuntime.originalImageId === expectedImageId + && report.restoredRuntime.currentImageId === expectedImageId + && report.restoredRuntime.originalFingerprint === report.restoredRuntime.currentFingerprint + && report.restoredRuntime.nacosSourcesUnchanged === true + && report.restoredRuntime.upstreamReads?.IAM === true && report.restoredRuntime.upstreamReads?.FINANCE === true, + 'RESTORED_IDENTITY_OR_ROUTES_NOT_PROVED') + const businessRecovery = await recovery() + report.businessRecovery = businessRecovery + report.restoration = await action('finish', { businessRecovery }) + assert(report.restoration.restored && report.restoration.proxyStopped && report.restoration.orderRoutesVerified, 'RESTORATION_NOT_COMPLETE') + } catch (error) { report.recoveryError = safeError(error); failure ||= error } + } + } + report.success = !failure && report.observations.length === 4 && Boolean(report.restoration?.restored) + if (failure) report.error = safeError(failure) + return report +} + +function businessClient(env, plan, fetcher = globalThis.fetch) { + const sessions = {} + const call = async (method, path, body, headers = {}, token) => { + const response = await fetcher(new URL(path, env.ADMIN_TEST_GATEWAY), { method, redirect:'error', signal:AbortSignal.timeout(20000), + headers:{'Content-Type':'application/json', ...headers, ...(token ? {Authorization:`Bearer ${token}`} : {})}, ...(body === undefined ? {} : {body:JSON.stringify(body)}) }) + return {http:response.status,body:await response.json()} + } + const ok = value => { assert(value.http === 200 && String(value.body?.code) === '200', 'BUSINESS_READ_OR_RECOVERY_FAILED'); return value.body.data } + const snapshot = async role => { + const command = plan.commands[role] + const detailPath = `/api/${role}/v1/orders/first-leg/${command.orderId}${role === 'platform' ? `?tenantId=${plan.customerId}` : ''}` + const detail = ok(await call('GET',detailPath,undefined,{},sessions[role].accessToken)) + assert(String(detail?.order?.id)===command.orderId && String(detail.order.tenantId)===plan.customerId + && ['cargoes','parties','internalNotes','statusHistory'].every(field=>Array.isArray(detail[field])), 'ORDER_SNAPSHOT_INCOMPLETE_OR_FOREIGN') + const taskResponse = await call('GET',`/api/platform/v1/first-leg/tasks/by-order/${command.orderId}?tenantId=${plan.customerId}`,undefined,{},sessions.platform.accessToken) + assert(taskResponse.http === 200 && ['200','A0404'].includes(String(taskResponse.body?.code)), 'TASK_SNAPSHOT_FAILED') + const task = String(taskResponse.body.code) === '200' ? taskResponse.body.data ?? null : null + const inbounds = []; let total + for (let page=1;;page++) { + const value=ok(await call('GET',`/api/platform/inbounds?tenantId=${plan.customerId}&orderNo=${encodeURIComponent(command.orderNo)}&page=${page}&size=200`,undefined,{},sessions.platform.accessToken)) + assert(Array.isArray(value.records) && Number(value.page)===page && Number.isSafeInteger(Number(value.total)), 'INBOUND_PAGE_INVALID') + total ??= Number(value.total); assert(Number(value.total)===total && value.records.length===Math.min(200,Math.max(0,total-inbounds.length)), 'INBOUND_PAGE_INCOMPLETE') + for(const row of value.records)assert(row.orderNo===command.orderNo && String(row.tenantId)===plan.customerId && !inbounds.some(before=>String(before.id)===String(row.id)), 'INBOUND_SCOPE_OR_DUPLICATE') + inbounds.push(...value.records);if(inbounds.length===total)break + } + return {detail,task,inbounds,inboundPageComplete:true} + } + const submit = (role, request) => call(request.method,request.path,request.body,request.headers,sessions[role].accessToken) + return { snapshot, submit, + async login() { + for(const [role,prefix] of [['platform','ADMIN_TEST'],['merchant','MERCHANT_TEST']]) { + assert(env[prefix+'_USERNAME'] && env[prefix+'_PASSWORD'], 'EXPLICIT_BUSINESS_CREDENTIALS_REQUIRED') + sessions[role]=ok(await call('POST','/api/auth/login',{loginId:env[prefix+'_USERNAME'],password:env[prefix+'_PASSWORD']})) + } + const profile=ok(await call('GET','/api/merchant/v1/iam/tenant/profile',undefined,{},sessions.merchant.accessToken)) + assert(String(profile.id)===plan.customerId && profile.status==='ACTIVE','REGISTERED_CUSTOMER_SESSION_MISMATCH') + }, + async recovery() { + const evidence={} + for(const role of ['merchant','platform']) { + const command=plan.commands[role] + ok(await submit(role,command)) + let before=await snapshot(role) + for(let i=0;i<10;i++) { await pause(500);const next=await snapshot(role);if(sha(before)===sha(next)){before=next;break}before=next } + assert(before.detail.order.status==='SUBMITTED' && before.detail.statusHistory.filter(row=>row.action==='SUBMITTED').length===1 + && before.inbounds.length<=1, 'RECOVERY_SINGLE_SUBMISSION_NOT_PROVED') + ok(await submit(role,command));const after=await snapshot(role) + assert(sha(before)===sha(after),'RECOVERY_REPLAY_DRIFT') + evidence[role]={orderId:command.orderId,status:'SUBMITTED',sameKeyReplayVerified:true,snapshotSha256:sha(after)} + } + return evidence + }, + async logout() { + let success=true + for(const session of Object.values(sessions))try {ok(await call('POST','/api/auth/logout',{refreshToken:session.refreshToken},{},session.accessToken))}catch{success=false} + return success + }, + } +} + +async function main(argv) { + const [mode,...rest]=argv, args={} + for(let i=0;i{console.error('ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1}) diff --git a/scripts/order-submit-dependency-fault-executor.test.mjs b/scripts/order-submit-dependency-fault-executor.test.mjs new file mode 100644 index 000000000..1e204dedf --- /dev/null +++ b/scripts/order-submit-dependency-fault-executor.test.mjs @@ -0,0 +1,209 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { buildOrderDependencyFaultPlan } from './admin-test-env-modules/orders.mjs' +import { scopeFor, renderOrderFaultAction, createOrderFaultRuntime, runOrderFaultWindow } from './order-submit-dependency-fault-executor.mjs' + +const image = 'sha256:' + 'a'.repeat(64) +function fixture() { + const detail = id => ({order:{id,tenantId:'20',orderNo:`FL${id}`,version:2,status:'DRAFT'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]}) + const raw={marker:'TEST-DEPENDENCY',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')} + return {raw,plan:buildOrderDependencyFaultPlan(raw)} +} +const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'never-print-this',FBA_CORE_JENKINS_AGENT:'test-agent'} + +test('renderer binds TEST scope and emits no Jenkins credential; ambiguous remote mutation is never retried',async()=>{ + const {plan}=fixture(),scope=scopeFor(plan) + assert.ok(!renderOrderFaultAction('status',scope,env.FBA_CORE_JENKINS_AGENT).includes(env.JENKINS_API_TOKEN)) + assert.throws(()=>renderOrderFaultAction('delete-all',scope,'agent')) + assert.throws(()=>renderOrderFaultAction('prepare',scope,'agent',{routing:{configurationReviewed:true,password:'must-not-embed'}})) + assert.throws(()=>createOrderFaultRuntime({...env,CHONGCHONG_ENV:'prod'},scope)) + for(const mutate of [p=>p.phases[0].expectedCode='A0403',p=>p.phases[0].targetPath+='/other',p=>p.phases[0].expectedMessages.push('anything')]) { + const changed=structuredClone(plan);mutate(changed);assert.throws(()=>scopeFor(changed)) + } + let calls=0 + const runtime=createOrderFaultRuntime(env,scope,async()=>{calls++;throw Error('connection lost with secret')}) + await assert.rejects(runtime('activate'),/OUTCOME_UNKNOWN_USE_STATUS/) + assert.equal(calls,1) +}) + +function windowFixture(fault='') { + const {raw,plan}=fixture(),calls=[],records=[] + let mode='PASS' + const options={plan,routing:{configurationReviewed:true},expectedImageId:image,wait:async()=>{}, + async action(action,extra={}) { + calls.push(action) + if(action==='prepare'&&fault==='prepare-unknown')throw Error('PREPARE_OUTCOME_UNKNOWN') + if(action==='restore'&&['restore-unknown','prepare-unknown'].includes(fault))throw Error('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + if(action==='status')return {proxy:{mode:'PASS'}} + if(action==='health')return {healthy:true} + if(action==='mode'){mode=extra.mode;return {mode}} + if(action==='records')return {records} + if(action==='verify-restored')return {originalImageId:image,currentImageId:image,originalFingerprint:'same',currentFingerprint:'same',upstreamReads:{IAM:true,FINANCE:true},nacosSourcesUnchanged:true} + if(action==='finish')return {restored:true,healthy:true,proxyStopped:true,orderRoutesVerified:true} + return {} + }, + async snapshot(role){return {detail:structuredClone(raw[role]),task:null,inbounds:[],inboundPageComplete:true}}, + async submit(role,request){ + const phase=plan.phases.find(item=>item.dependency===mode) + records.push({requestId:request.headers['X-Request-Id'],dependency:mode,method:'GET',path:phase.targetPath, + matched:true,responseStatus:503,forwarded:false,internalTokenPresent:true}) + return {http:200,body:{code:fault==='wrong-business-code'?'A0403':'S0503',message:phase.expectedMessages[0]}} + }, + async recovery(){calls.push('business-recovery');if(fault==='business-recovery-failed')throw Error('RECOVERY_REPLAY_DRIFT') + return {merchant:{orderId:'101',status:'SUBMITTED',sameKeyReplayVerified:true},platform:{orderId:'102',status:'SUBMITTED',sameKeyReplayVerified:true}}}, + } + return {options,calls} +} + +test('four observations restore original routes and verify business recovery before stopping proxy',async()=>{ + const {options,calls}=windowFixture(),report=await runOrderFaultWindow(options) + assert.equal(report.success,true);assert.equal(report.observations.length,4) + assert.ok(calls.indexOf('restore'){ + const {options,calls}=windowFixture('wrong-business-code'),report=await runOrderFaultWindow(options) + assert.equal(report.success,false);assert.equal(report.restoration.restored,true) + assert.ok(calls.includes('finish')) +}) +for(const fault of ['prepare-unknown','restore-unknown','business-recovery-failed'])test(`unsafe cleanup blocks finish: ${fault}`,async()=>{ + const {options,calls}=windowFixture(fault),report=await runOrderFaultWindow(options) + assert.equal(report.success,false);assert.equal(calls.filter(action=>action==='prepare').length,1) + assert.equal(calls.filter(action=>action==='restore').length,1);assert.ok(!calls.includes('finish')) +}) + +test('offline runtime journals Docker transitions, reconciles observed outcomes and retains original configuration (no Docker/HTTP)',()=>{ + const script=String.raw` +import importlib.util,sys,copy,json,unittest +from pathlib import Path +spec=importlib.util.spec_from_file_location('runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +common=m.common_tools(Path(sys.argv[2]).read_text(encoding='utf-8')) +class Fake: + def __init__(self): + self.current={'Id':'original-container-id','Name':'/firstleg-test-order','Image':'sha256:'+'a'*64, + 'Config':{'Image':'offline/order:tag','Env':['DEPLOY_ENV=test','SERVER_PORT=9204','INTERNAL_SERVICE_TOKEN=offline-secret-long'], + 'Entrypoint':copy.deepcopy(m.ENTRYPOINT),'Cmd':None,'Labels':{'com.docker.compose.project':'firstleg-test'}}, + 'HostConfig':{'AutoRemove':False,'ExtraHosts':['host.docker.internal:host-gateway']},'Mounts':[], + 'NetworkSettings':{'Networks':{'test':{'IPAddress':'172.20.0.10','NetworkID':'n1','Aliases':['order','original-con'],'MacAddress':'aa:bb:cc:dd:ee:ff'}}}, + 'State':{'Running':True,'Health':{'Status':'healthy'}}} + self.original=copy.deepcopy(self.current);self.calls=[];self.fail=None;self.failBefore=None;self.serial=0 + def inspect(self,name): + if not self.current or name not in ('firstleg-test-order',self.current['Id']):raise RuntimeError('DOCKER_404') + return copy.deepcopy(self.current) + def docker(self,method,path,body=None): + if method=='GET':return {'Id':self.original['Image']} + self.calls.append((method,path));result=None + if self.failBefore=='stop' and '/stop?' in path:raise RuntimeError('REQUEST_OUTCOME_UNKNOWN') + if '/stop?' in path:self.current['State']['Running']=False;op='stop' + elif method=='DELETE':self.current=None;op='delete' + elif '/create?' in path: + self.serial+=1;new_id='new-container-'+str(self.serial);op='create' + self.current={'Id':new_id,'Name':'/firstleg-test-order','Image':self.original['Image'], + 'Config':{k:copy.deepcopy(v) for k,v in body.items() if k not in ('HostConfig','NetworkingConfig')}, + 'HostConfig':copy.deepcopy(body['HostConfig']),'Mounts':[], + 'NetworkSettings':{'Networks':{}},'State':{'Running':False,'Health':{'Status':'healthy'}}} + for name,entry in body['NetworkingConfig']['EndpointsConfig'].items(): + self.current['NetworkSettings']['Networks'][name]={'IPAddress':entry['IPAMConfig']['IPv4Address'],'NetworkID':'n1', + 'Aliases':entry['Aliases']+[new_id[:12]],'MacAddress':entry['MacAddress'],'IPAMConfig':entry['IPAMConfig']} + result={'Id':new_id} + elif path.endswith('/start'):self.current['State']['Running']=True;op='start' + else:raise AssertionError(path) + if self.fail==op:self.fail=None;raise RuntimeError('RESPONSE_LOST') + return result +class RuntimeTests(unittest.TestCase): + def fixture(self): + f=Fake();tools=dict(common);tools.update(inspect=f.inspect,docker=f.docker) + scope={'marker':'OFFLINE','customerId':'7','orderIds':{'merchant':'10','platform':'11'},'planSha256':'b'*64} + r=m.Runtime(scope,tools,Path('offline-not-created'));saved=[];controls=[] + r.save=lambda state:saved.append(copy.deepcopy(state));r.control=lambda state,mode=None,ttl=0:(controls.append(mode) or {'mode':'PASS'}) + r.probes=lambda state:{'IAM':True,'FINANCE':True} + r.config_fingerprint=lambda container,routing:'reviewed-config' + state={'scope':scope,'phase':'PROXY_STARTED','originalId':f.original['Id'],'originalImageId':f.original['Image'], + 'originalFingerprint':common['fingerprint'](f.original),'originalBody':common['recreation'](f.original,common['env_of'](f.original),full=True), + 'proxyPort':12345,'controlPort':12346,'mutation':None,'routing':{},'routingSourceSha256':'reviewed-config'} + return f,r,state,saved,controls + def test_original_fingerprint_config_and_image_restored(self): + f,r,s,saved,controls=self.fixture();r.activate(s) + self.assertTrue(r.matches_injected(f.current,s));self.assertEqual(s['phase'],'INJECTED') + self.assertTrue(any(x.get('mutation',{}).get('step')=='CREATE_INTENT' for x in saved if x.get('mutation'))) + r.restore(s);result=r.verify_restored(s) + self.assertEqual(result['originalFingerprint'],result['currentFingerprint']);self.assertEqual(f.current['Config'],f.original['Config']) + def test_unknown_outcomes_never_auto_repeat_docker_and_can_be_explicitly_reconciled(self): + for operation in ['stop','delete','create','start']: + f,r,s,saved,controls=self.fixture();f.fail=operation + with self.assertRaisesRegex(RuntimeError,'UNCERTAIN'):r.activate(s) + count=len(f.calls) + with self.assertRaisesRegex(RuntimeError,'RECONCILE'):r.restore(s) + self.assertEqual(len(f.calls),count);self.assertEqual(controls[-1],'PASS') + r.reconcile(s);self.assertEqual(len(f.calls),count) + r.restore(s);self.assertTrue(r.verify_restored(s)['healthy']) + def test_unrecognized_configuration_is_never_overwritten(self): + f,r,s,saved,controls=self.fixture();r.activate(s);f.current['Config']['Env'].append('UNRELATED_CHANGE=1');count=len(f.calls) + with self.assertRaisesRegex(RuntimeError,'UNRECOGNIZED'):r.restore(s) + self.assertEqual(len(f.calls),count) + def test_unknown_stop_without_observed_stop_is_not_misreported_as_restored(self): + f,r,s,saved,controls=self.fixture();f.failBefore='stop' + with self.assertRaisesRegex(RuntimeError,'UNCERTAIN'):r.activate(s) + count=len(f.calls) + with self.assertRaisesRegex(RuntimeError,'NO_BLIND_RECREATE'):r.reconcile(s) + self.assertTrue(s['uncertain']);self.assertTrue(f.current['State']['Running']);self.assertEqual(len(f.calls),count) + def test_restore_still_runs_when_proxy_control_is_unreachable(self): + f,r,s,saved,controls=self.fixture();r.activate(s) + def unavailable(*args):raise RuntimeError('CONTROL_LOST') + r.control=unavailable;r.restore(s);self.assertTrue(r.verify_restored(s)['healthy']) + def test_finish_refuses_unproved_business_route_recovery(self): + f,r,s,saved,controls=self.fixture();r.activate(s);r.restore(s) + from unittest.mock import patch + with patch.object(Path,'read_text',return_value='OFFLINE'),patch.object(Path,'is_symlink',return_value=False): + with self.assertRaisesRegex(RuntimeError,'ROUTES_NOT_PROVED'):r.finish(s,{}) + def test_changed_nacos_configuration_prevents_recovery_claim(self): + f,r,s,saved,controls=self.fixture();r.activate(s);r.restore(s);r.config_fingerprint=lambda *args:'changed' + with self.assertRaisesRegex(RuntimeError,'NACOS_ROUTE_CONFIGURATION_CHANGED'):r.verify_restored(s) + def test_nacos_source_reads_are_explicit_authenticated_and_redacted(self): + from unittest.mock import patch + f,r,s,saved,controls=self.fixture() + f.current['Config']['Env']+=['NACOS_SERVER_ADDR=host.docker.internal:8848','NACOS_USERNAME=offline-user', + 'NACOS_PASSWORD=offline-password','NACOS_GROUP=BAIYUE','NACOS_NAMESPACE=test-only'] + routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v1/auth/users/login','configPath':'/nacos/v1/cs/configs'}} + calls=[] + class Response: + status=200 + def __init__(self,value):self.value=value + def __enter__(self):return self + def __exit__(self,*args):pass + def read(self):return self.value + def read(req,timeout): + calls.append(req) + return Response(b'{"accessToken":"offline-token"}' if not isinstance(req,str) else b'spring:\n test: only\n') + with patch.object(m,'http_open',side_effect=read): + value=m.Runtime.config_fingerprint(r,f.current,routing) + self.assertEqual(len(value),64);self.assertEqual(len(calls),3) + self.assertIn(b'password=offline-password',calls[0].data) + self.assertTrue(all('accessToken=offline-token' in url for url in calls[1:])) + routing['nacos']['baseUrl']='http://other.invalid:8848' + with self.assertRaisesRegex(RuntimeError,'EXPLICIT_NACOS'):m.Runtime.config_fingerprint(r,f.current,routing) + self.assertEqual(len(calls),3) + def test_effective_source_extraction_excludes_all_event_and_sql_functions(self): + self.assertEqual(set(common),{'DockerConnection','docker','inspect','env_of','require_test','recreation','fingerprint'}) + def test_control_token_is_not_sent_to_an_unowned_listener(self): + from unittest.mock import patch + f,r,s,saved,controls=self.fixture();s.update(proxyPid=777,controlToken='private-control') + command=str(r.directory/'proxy.py').encode()+b'\0' + tcp='header\n 0: 00000000:3039 00000000:0000 0A 0:0 0:0 0 1000 0 123\n' + with patch.object(Path,'read_bytes',return_value=command),patch.object(Path,'iterdir',return_value=iter([Path('fd')])), \ + patch.object(m.os,'readlink',return_value='socket:[123]'),patch.object(Path,'read_text',return_value=tcp), \ + patch.object(m,'http_open') as request: + with self.assertRaisesRegex(RuntimeError,'LISTENERS_NOT_OWNED'):m.Runtime.control(r,s) + request.assert_not_called() +result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(RuntimeTests)) +sys.exit(0 if result.wasSuccessful() else 1) +` + const result=spawnSync(process.env.PYTHON||'python',['-c',script, + fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url)), + fileURLToPath(new URL('./test-event-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}) + assert.equal(result.status,0,result.stderr || result.error?.message) + assert.match(result.stderr,/Ran 10 tests/) +}) diff --git a/scripts/order-submit-dependency-fault-proxy.py b/scripts/order-submit-dependency-fault-proxy.py new file mode 100644 index 000000000..240507e8d --- /dev/null +++ b/scripts/order-submit-dependency-fault-proxy.py @@ -0,0 +1,176 @@ +"""TEST-only scoped synchronous dependency proxy. Default/expired mode is PASS. + +Configuration and control token are read from a mode-0600 file, never argv values. +The runtime owns lifecycle; this file never invokes Docker, SQL or a shell. +""" +import argparse +import hmac +import http.client +import json +import os +import re +import threading +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HOP = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'} + + +def forward_headers(headers, length): + connection = {k.lower(): v for k, v in headers.items()}.get('connection', '') + excluded = HOP | {x.strip().lower() for x in connection.split(',')} + result = {k: v for k, v in headers.items() if k.lower() not in excluded} + result['Content-Length'] = str(length) + return result + + +def transport(upstream, method, path, headers, body): + url = urllib.parse.urlsplit(upstream) + cls = http.client.HTTPSConnection if url.scheme == 'https' else http.client.HTTPConnection + conn = cls(url.hostname, url.port, timeout=8) + try: + conn.request(method, path, body, forward_headers(headers, len(body))) + response = conn.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + conn.close() + + +class Router: + def __init__(self, config, forward=transport, clock=time.monotonic, record=None): + self.config, self.forward, self.clock = config, forward, clock + self.mode, self.deadline, self.generation = 'PASS', 0, 0 + self.lock = threading.Lock() + self.record = record or (lambda row: None) + self.counts = {'forwarded': 0, 'faulted': 0} + customer = config['customerId'] + if not re.fullmatch(r'[1-9][0-9]{0,19}', customer): + raise ValueError('INVALID_CUSTOMER') + if not config.get('requestIds') or any(not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', value) for value in config['requestIds']): + raise ValueError('BOUNDED_REQUEST_IDS_REQUIRED') + self.paths = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility', + 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer} + + def status(self): + with self.lock: + if self.mode != 'PASS' and self.clock() >= self.deadline: + self.mode, self.deadline = 'PASS', 0 + return {'mode': self.mode, 'generation': self.generation, + 'remainingSeconds': max(0, self.deadline - self.clock()), **self.counts} + + def set_mode(self, token, mode, ttl=0): + if not hmac.compare_digest(str(token), self.config['controlToken']): + raise PermissionError('CONTROL_AUTH_REQUIRED') + with self.lock: + # Even an authenticated malformed command disarms first. + self.mode, self.deadline = 'PASS', 0 + if mode not in ('PASS', 'IAM', 'FINANCE') or (mode != 'PASS' and + (type(ttl) is not int or not 1 <= ttl <= 60)): + raise ValueError('INVALID_MODE_OR_TTL') + self.mode = mode + self.deadline = self.clock() + ttl if mode != 'PASS' else 0 + self.generation += 1 + return self.status() + + def dispatch(self, method, path, headers, body, peer): + prefix, separator, rest = path.lstrip('/').partition('/') + dependency = {'iam': 'IAM', 'finance': 'FINANCE'}.get(prefix) + if not dependency or not separator: + return 404, {}, b'Unknown dependency route' + actual_path = '/' + rest + normalized = {k.lower(): v for k, v in headers.items()} + token = normalized.get('x-internal-service-token', '') + authenticated = bool(token) and hmac.compare_digest(token, self.config['internalToken']) + target = method == 'GET' and actual_path == self.paths[dependency] + request_id = normalized.get('x-request-id', '') + selected = target and peer in self.config['orderIps'] and authenticated and request_id in self.config['requestIds'] + mode = self.status()['mode'] + fault = selected and mode == dependency + # Only bounded correlation IDs and this customer's selected paths enter evidence. + safe_id = request_id if re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', request_id) else None + if fault: + result = (503, {'Content-Type': 'text/plain'}, b'TEST dependency temporarily unavailable') + else: + result = self.forward(self.config['upstreams'][dependency], method, actual_path, headers, body) + with self.lock: + self.counts['faulted' if fault else 'forwarded'] += 1 + if selected: + self.record({'requestId': safe_id, 'method': method, 'path': actual_path, + 'dependency': dependency, 'matched': fault, 'responseStatus': result[0], + 'forwarded': not fault, 'internalTokenPresent': True, + 'internalTokenValidated': True, 'mode': mode}) + return result + + +def main(path): + config_path = Path(path) + if config_path.is_symlink() or config_path.stat().st_mode & 0o077: + raise RuntimeError('PRIVATE_CONFIG_REQUIRED') + config = json.loads(config_path.read_text()) + evidence_path = config_path.parent / 'proxy-evidence.jsonl' + evidence_lock = threading.Lock() + + def record(row): + with evidence_lock: + fd = os.open(evidence_path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(fd, 'w') as stream: + stream.write(json.dumps(row) + '\n'); stream.flush(); os.fsync(stream.fileno()) + + router = Router(config, record=record) + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def dispatch(self): + try: + length = int(self.headers.get('Content-Length', '0')) + if length < 0 or length > 2 * 1024 * 1024: + raise ValueError('BODY_LIMIT') + body = self.rfile.read(length) + if self.server.control: + token = self.headers.get('X-Order-Fault-Control', '') + if not hmac.compare_digest(token, config['controlToken']): + raise PermissionError('CONTROL_AUTH_REQUIRED') + if self.command == 'GET' and self.path == '/status': + value = router.status() + elif self.command == 'POST' and self.path == '/mode': + value = json.loads(body) + value = router.set_mode(token, value.get('mode'), value.get('ttl', 0)) + else: + raise ValueError('UNKNOWN_CONTROL') + status, headers, response = 200, {'Content-Type': 'application/json'}, json.dumps(value).encode() + else: + status, headers, response = router.dispatch(self.command, self.path, dict(self.headers), body, self.client_address[0]) + except PermissionError: + status, headers, response = 403, {}, b'Forbidden' + except (ValueError, KeyError): + if self.server.control and hmac.compare_digest(self.headers.get('X-Order-Fault-Control', ''), config['controlToken']): + router.set_mode(config['controlToken'], 'PASS') + status, headers, response = 400, {}, b'Invalid request' + except Exception: + status, headers, response = 502, {}, b'Upstream transport failed' + self.send_response(status) + for name, value in headers.items(): + if name.lower() not in HOP: + self.send_header(name, value) + self.send_header('Content-Length', str(len(response))); self.end_headers() + if self.command != 'HEAD': + self.wfile.write(response) + + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = dispatch + + business = ThreadingHTTPServer(('0.0.0.0', config['proxyPort']), Handler); business.control = False + control = ThreadingHTTPServer(('127.0.0.1', config['controlPort']), Handler); control.control = True + (config_path.parent / 'proxy.pid').write_text(str(os.getpid())) + os.chmod(config_path.parent / 'proxy.pid', 0o600) + threading.Thread(target=business.serve_forever, daemon=True).start() + control.serve_forever() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(); parser.add_argument('--config', required=True) + main(parser.parse_args().config) diff --git a/scripts/order-submit-dependency-fault-proxy.test.mjs b/scripts/order-submit-dependency-fault-proxy.test.mjs new file mode 100644 index 000000000..73b4c64f6 --- /dev/null +++ b/scripts/order-submit-dependency-fault-proxy.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +test('offline proxy routing/authentication/TTL/transparency and redaction (no sockets or HTTP)', () => { + const script = String.raw` +import importlib.util,json,sys,unittest +spec=importlib.util.spec_from_file_location('proxy',sys.argv[1]);p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p) +class ProxyTests(unittest.TestCase): + def setUp(self): + self.now=[10.0];self.calls=[];self.logs=[] + self.config={'customerId':'7','controlToken':'private-control','internalToken':'private-internal-token', + 'orderIps':['172.20.0.10'],'requestIds':['fixture-request'],'upstreams':{'IAM':'http://iam.invalid:9201','FINANCE':'http://finance.invalid:9207'}} + def forward(*args): self.calls.append(args);return 201,{'X-Upstream':'kept'},b'original-response' + self.router=p.Router(self.config,forward=forward,clock=lambda:self.now[0],record=self.logs.append) + self.headers={'X-Internal-Service-Token':'private-internal-token','X-Request-Id':'fixture-request'} + self.path='/iam/internal/iam/tenants/7/order-eligibility' + def send(self,path=None,method='GET',headers=None,peer='172.20.0.10'): + return self.router.dispatch(method,path or self.path,headers or self.headers,b'original-body',peer) + def test_default_pass_and_transport_are_unchanged(self): + self.assertEqual(self.send(),(201,{'X-Upstream':'kept'},b'original-response')) + self.assertEqual(self.calls[0][2],'/internal/iam/tenants/7/order-eligibility') + self.assertEqual(self.calls[0][3]['X-Internal-Service-Token'],'private-internal-token') + self.assertEqual(self.calls[0][4],b'original-body') + def test_only_exact_authenticated_customer_get_from_order_faults(self): + self.router.set_mode('private-control','IAM',60) + self.assertEqual(self.send()[0],503);self.assertEqual(len(self.calls),0) + for path,method,headers,peer in [ + (self.path.replace('/7/','/8/'),'GET',self.headers,'172.20.0.10'), + (self.path+'?other=1','GET',self.headers,'172.20.0.10'), + (self.path,'POST',self.headers,'172.20.0.10'), + (self.path,'GET',{'X-Internal-Service-Token':'wrong'},'172.20.0.10'), + (self.path,'GET',{**self.headers,'X-Request-Id':'other-order-request'},'172.20.0.10'), + (self.path,'GET',self.headers,'172.20.0.11'), + ('/finance/internal/finance/v1/wallet/accounts/7','GET',self.headers,'172.20.0.10'), + ('/iam/internal/iam/finance-customers?page=1','GET',self.headers,'172.20.0.10')]: + self.assertEqual(self.send(path,method,headers,peer)[0],201) + def test_expiry_needs_no_control_request_and_returns_to_pass(self): + self.router.set_mode('private-control','FINANCE',5) + path='/finance/internal/finance/v1/wallet/accounts/7' + self.assertEqual(self.send(path)[0],503) + self.now[0]=15.0 + self.assertEqual(self.send(path)[0],201);self.assertEqual(self.router.status()['mode'],'PASS') + def test_control_auth_and_malformed_authenticated_command(self): + self.router.set_mode('private-control','IAM',10) + with self.assertRaises(PermissionError):self.router.set_mode('wrong','PASS') + for mode,ttl in [('UNKNOWN',10),('IAM',0),('IAM',61),('IAM',True)]: + with self.assertRaises(ValueError):self.router.set_mode('private-control',mode,ttl) + self.assertEqual(self.router.status()['mode'],'PASS') + def test_evidence_contains_no_secret_headers_or_payload(self): + self.router.set_mode('private-control','IAM',10);self.send() + serialized=json.dumps(self.logs) + for secret in ['private-control','private-internal-token','original-body']:self.assertNotIn(secret,serialized) + self.assertTrue(self.logs[0]['internalTokenValidated']);self.assertFalse(self.logs[0]['forwarded']) + self.assertEqual(self.logs[0]['requestId'],'fixture-request') + def test_hop_headers_removed_but_identity_preserved(self): + headers=p.forward_headers({'connection':'x-hop','x-hop':'drop','Host':'old','X-Internal-Service-Token':'keep','Content-Length':'99'},3) + self.assertNotIn('x-hop',headers);self.assertNotIn('Host',headers) + self.assertEqual(headers['X-Internal-Service-Token'],'keep');self.assertEqual(headers['Content-Length'],'3') +result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(ProxyTests)) +sys.exit(0 if result.wasSuccessful() else 1) +` + const result = spawnSync(process.env.PYTHON || 'python', ['-c', script, + fileURLToPath(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url))], + { encoding:'utf8', env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'} }) + assert.equal(result.status,0,result.stderr || result.error?.message) + assert.match(result.stderr,/Ran 6 tests/) +}) diff --git a/scripts/order-submit-dependency-fault-runtime.py b/scripts/order-submit-dependency-fault-runtime.py new file mode 100644 index 000000000..3823f2d2a --- /dev/null +++ b/scripts/order-submit-dependency-fault-runtime.py @@ -0,0 +1,445 @@ +"""Bounded remote TEST actions; injected by the JS executor, never invoked at import. + +Only Order is recreated. Every Docker mutation is journaled before sending; an +uncertain result blocks further mutations until explicit read-only reconciliation. +""" +import ast +import base64 +import copy +import hashlib +import http.client +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + +ORDER = 'firstleg-test-order' +ENTRYPOINT = ['sh', '-c', 'exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher'] +PROPERTIES = {'IAM': 'baiyue.clients.iam.url', 'FINANCE': 'baiyue.clients.finance.url'} +UPSTREAM_CONTAINERS = {'IAM': 'firstleg-test-iam', 'FINANCE': 'firstleg-test-finance'} + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): return None + + +def http_open(request, timeout): + return urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) + + +def common_tools(source): + """Reuse only Docker read/config reconstruction primitives, never event/SQL functions.""" + tree = ast.parse(source) + allowed = {'DockerConnection', 'docker', 'inspect', 'env_of', 'require_test', 'recreation', 'fingerprint'} + nodes = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom)) + or isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in allowed] + namespace = {'__name__': 'order_dependency_primitives'} + exec(compile(ast.Module(body=nodes, type_ignores=[]), '', 'exec'), namespace) + return {name: namespace[name] for name in allowed} + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def private_write(path, value): + temporary = path.with_name(path.name + '.next') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream); stream.flush(); os.fsync(stream.fileno()) + os.replace(temporary, path) + parent = os.open(path.parent, os.O_RDONLY) + try: os.fsync(parent) + finally: os.close(parent) + + +def validate_scope(scope): + if set(scope) != {'marker', 'customerId', 'orderIds', 'planSha256'} or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', scope['marker']): + raise RuntimeError('INVALID_SCOPE') + if not re.fullmatch(r'[1-9][0-9]{0,19}', scope['customerId']) or set(scope['orderIds']) != {'merchant', 'platform'}: + raise RuntimeError('INVALID_SCOPE') + if any(not re.fullmatch(r'[1-9][0-9]{0,19}', value) for value in scope['orderIds'].values()) or len(set(scope['orderIds'].values())) != 2: + raise RuntimeError('TWO_DISTINCT_ORDERS_REQUIRED') + if not re.fullmatch(r'[0-9a-f]{64}', scope['planSha256']): raise RuntimeError('INVALID_PLAN_HASH') + + +class Runtime: + def __init__(self, scope, tools, base=Path('/tmp')): + validate_scope(scope) + self.scope, self.tools = scope, tools + self.directory = base / ('chongchong-order-dependency-' + scope['marker']) + self.path = self.directory / 'state.json' + self.lock = base / 'chongchong-order-dependency.lock' + + def save(self, state): private_write(self.path, state) + + def load(self): + if self.directory.is_symlink() or self.directory.stat().st_uid != os.getuid() or self.directory.stat().st_mode & 0o077: + raise RuntimeError('UNSAFE_STATE_DIRECTORY') + if self.path.is_symlink() or self.path.stat().st_mode & 0o077: raise RuntimeError('UNSAFE_STATE_FILE') + state = json.loads(self.path.read_text()) + if state['scope'] != self.scope: raise RuntimeError('SCOPE_CHANGED') + if (state.get('mutation') or {}).get('step', '').endswith('_INTENT'): + state['uncertain'] = True # Includes process death between HTTP completion and journal commit. + return state + + def container(self): + value = self.tools['inspect'](ORDER) + self.tools['require_test'](value, ORDER) + if self.tools['env_of'](value).get('DEPLOY_ENV') != 'test': raise RuntimeError('NOT_TEST_ENV') + return value + + def control(self, state, mode=None, ttl=0): + pid = state.get('proxyPid') + if not isinstance(pid, int) or pid <= 1: raise RuntimeError('PROXY_PID_NOT_READY') + process = Path('/proc') / str(pid) + if str(self.directory / 'proxy.py').encode() not in (process / 'cmdline').read_bytes().split(b'\0'): + raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') + socket_inodes = set() + for descriptor in (process / 'fd').iterdir(): + try: target = os.readlink(descriptor) + except FileNotFoundError: continue + match = re.fullmatch(r'socket:\[(\d+)\]', target) + if match: socket_inodes.add(match.group(1)) + owned_ports = set() + for line in Path('/proc/net/tcp').read_text().splitlines()[1:]: + values = line.split() + if len(values) > 9 and values[3] == '0A' and values[9] in socket_inodes: + owned_ports.add(int(values[1].split(':')[1], 16)) + if not {state['proxyPort'], state['controlPort']}.issubset(owned_ports): raise RuntimeError('PROXY_LISTENERS_NOT_OWNED') + req = urllib.request.Request('http://127.0.0.1:' + str(state['controlPort']) + ('/mode' if mode else '/status'), + None if mode is None else json.dumps({'mode': mode, 'ttl': ttl}).encode(), + {'X-Order-Fault-Control': state['controlToken'], 'Content-Type': 'application/json'}) + with http_open(req, timeout=5) as response: return json.load(response) + + def probes(self, state): + """Real authenticated reads, not manufactured business responses. No body/secret is returned.""" + customer = self.scope['customerId']; results = {} + targets = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility', + 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer} + for dependency, path in targets.items(): + req = urllib.request.Request(state['upstreams'][dependency] + path, + headers={'X-Internal-Service-Token': state['internalToken']}) + with http_open(req, timeout=8) as response: + value = json.load(response) + if response.status != 200 or str(value.get('code')) != '200': raise RuntimeError('UPSTREAM_PROBE_FAILED') + data = value.get('data') or {} + if dependency == 'IAM' and data.get('eligible') is not True: raise RuntimeError('CUSTOMER_NOT_ELIGIBLE') + if dependency == 'FINANCE' and (data.get('actualBalance') is None or float(data['actualBalance']) < 0): + raise RuntimeError('CUSTOMER_BALANCE_NOT_READY') + results[dependency] = True + return results + + def config_fingerprint(self, container, routing): + """Read the two actual Nacos imports using explicitly reviewed v1 endpoints. + No endpoint guessing/fallback and no Nacos write operation is supported. + """ + env = self.tools['env_of'](container); config = routing.get('nacos', {}) + base = urllib.parse.urlsplit(config.get('baseUrl', '')) + configured = urllib.parse.urlsplit(env.get('NACOS_SERVER_ADDR', '') if '://' in env.get('NACOS_SERVER_ADDR', '') + else 'http://' + env.get('NACOS_SERVER_ADDR', '')) + allowed_hosts = {configured.hostname, '127.0.0.1', 'localhost'} | { + network.get('Gateway') for network in container['NetworkSettings']['Networks'].values()} + if (base.scheme not in ('http', 'https') or base.hostname not in allowed_hosts or base.port != configured.port + or base.path not in ('', '/') or base.username or base.password or base.query or base.fragment + or config.get('loginPath') not in ('/nacos/v1/auth/users/login', '/nacos/v1/auth/login') + or config.get('configPath') != '/nacos/v1/cs/configs'): + raise RuntimeError('EXPLICIT_NACOS_READ_ENDPOINTS_REQUIRED') + if any(not env.get(key) for key in ['NACOS_USERNAME', 'NACOS_PASSWORD', 'NACOS_GROUP', 'NACOS_NAMESPACE']): + raise RuntimeError('NACOS_READ_IDENTITY_UNAVAILABLE') + origin = config['baseUrl'].rstrip('/') + request = urllib.request.Request(origin + config['loginPath'], urllib.parse.urlencode({ + 'username': env['NACOS_USERNAME'], 'password': env['NACOS_PASSWORD']}).encode(), + {'Content-Type': 'application/x-www-form-urlencoded'}) + with http_open(request, timeout=8) as response: token = json.load(response).get('accessToken') + if not isinstance(token, str) or not token: raise RuntimeError('NACOS_READ_LOGIN_FAILED') + fingerprints = {} + for data_id in ('baiyue-common.yml', 'order-service.yml'): + query = urllib.parse.urlencode({'dataId': data_id, 'group': env['NACOS_GROUP'], 'tenant': env['NACOS_NAMESPACE'], 'accessToken': token}) + with http_open(origin + config['configPath'] + '?' + query, timeout=8) as response: + if response.status != 200: raise RuntimeError('NACOS_SOURCE_READ_FAILED') + content = response.read() + if not content or content.lstrip().startswith((b'{', b'<')): raise RuntimeError('NACOS_SOURCE_NOT_YAML') + fingerprints[data_id] = hashlib.sha256(content).hexdigest() + return digest({'imports': fingerprints, 'namespace': env['NACOS_NAMESPACE'], 'group': env['NACOS_GROUP']}) + + def prepare(self, config): + if self.directory.exists(): raise RuntimeError('STATE_EXISTS_USE_STATUS_OR_RESTORE') + original = self.container() + if original['Image'] != config.get('expectedImageId') or not re.fullmatch(r'sha256:[0-9a-f]{64}', original['Image']): + raise RuntimeError('ORIGINAL_IMAGE_MISMATCH') + if original['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('ORDER_NOT_HEALTHY') + if original['Config']['Entrypoint'] != ENTRYPOINT or original['Config'].get('Cmd') not in (None, []): + raise RuntimeError('UNREVIEWED_JAVA_ENTRYPOINT') + if 'host.docker.internal:host-gateway' not in (original['HostConfig'].get('ExtraHosts') or []): + raise RuntimeError('REVIEWED_PROXY_HOST_ROUTE_REQUIRED') + env = self.tools['env_of'](original) + token = env.get('INTERNAL_SERVICE_TOKEN', '') + if len(token) < 16: raise RuntimeError('INTERNAL_TOKEN_UNAVAILABLE') + routing = config.get('routing', {}) + if routing.get('configurationReviewed') is not True: + raise RuntimeError('UPSTREAM_CONFIGURATION_REVIEW_REQUIRED') + source_digest = self.config_fingerprint(original, routing) + if routing.get('sourceSha256') != source_digest: raise RuntimeError('REVIEWED_NACOS_SOURCE_DIGEST_CHANGED') + upstreams = routing.get('upstreams', {}) + if set(upstreams) != set(PROPERTIES): raise RuntimeError('EXPLICIT_UPSTREAMS_REQUIRED') + for dependency, upstream in upstreams.items(): + url = urllib.parse.urlsplit(upstream) + target = self.tools['inspect'](UPSTREAM_CONTAINERS[dependency]); self.tools['require_test'](target, UPSTREAM_CONTAINERS[dependency]) + target_env = self.tools['env_of'](target) + ips = {network['IPAddress'] for name, network in target['NetworkSettings']['Networks'].items() + if name in original['NetworkSettings']['Networks']} + if (url.scheme != 'http' or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment + or url.hostname not in ips or str(url.port) != target_env.get('SERVER_PORT') + or target_env.get('DEPLOY_ENV') != 'test' or target['State'].get('Health', {}).get('Status') != 'healthy'): + raise RuntimeError('UPSTREAM_NOT_VERIFIED_TEST_CONTAINER') + lock_fd = os.open(self.lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(lock_fd, 'w') as stream: stream.write(self.scope['marker']); stream.flush(); os.fsync(stream.fileno()) + self.directory.mkdir(mode=0o700) + sockets = [socket.socket(), socket.socket()] + for sock in sockets: sock.bind(('127.0.0.1', 0)) + proxy_port, control_port = [sock.getsockname()[1] for sock in sockets] + for sock in sockets: sock.close() + state = {'scope': self.scope, 'phase': 'PREPARED', 'originalId': original['Id'], + 'originalImageId': original['Image'], 'originalFingerprint': self.tools['fingerprint'](original), + 'originalBody': self.tools['recreation'](original, env, full=True), 'internalToken': token, + 'proxyPort': proxy_port, 'controlPort': control_port, 'controlToken': secrets.token_urlsafe(32), + 'upstreams': {k: v.rstrip('/') for k, v in upstreams.items()}, 'routingSourceSha256': source_digest, 'routing': routing, + 'orderIps': [x['IPAddress'] for x in original['NetworkSettings']['Networks'].values()], 'mutation': None, 'proxyLaunchIntent': False} + self.save(state) # Recovery info exists before any process or container mutation. + self.probes(state) + proxy = self.directory / 'proxy.py'; proxy.write_bytes(base64.b64decode(config['proxySource'])); os.chmod(proxy, 0o600) + private_write(self.directory / 'proxy-config.json', {k: state[k] for k in + ['internalToken', 'controlToken', 'proxyPort', 'controlPort', 'upstreams', 'orderIps']} | {'customerId': self.scope['customerId'], + 'requestIds': ['dep-' + self.scope['marker'] + '-' + dependency + '-' + role + for dependency in ('IAM', 'FINANCE') for role in ('merchant', 'platform')]}) + state['phase'] = 'PROXY_START_INTENT'; state['proxyLaunchIntent'] = True; self.save(state) + with open(self.directory / 'proxy-output.log', 'ab') as output: + os.chmod(self.directory / 'proxy-output.log', 0o600) + process = subprocess.Popen(['python3', str(proxy), '--config', str(self.directory / 'proxy-config.json')], + stdout=output, stderr=output, start_new_session=True) + state['proxyPid'] = process.pid; state['phase'] = 'PROXY_STARTED'; self.save(state) + return self.summary(state) + + def summary(self, state): + return {'scope': self.scope, 'phase': state['phase'], 'originalImageId': state['originalImageId'], + 'originalFingerprint': state['originalFingerprint'], 'uncertain': bool(state.get('uncertain')), + 'mutationStep': (state.get('mutation') or {}).get('step'), 'restored': state.get('restored', False)} + + def mutation(self, state, step, method, path, body=None): + if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + state['mutation'] = {'step': step, 'path': path, 'bodySha256': digest(body), 'intentAt': time.time()}; self.save(state) + try: + result = self.tools['docker'](method, path, body) + if step == 'CREATE_INTENT' and (not isinstance(result, dict) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', result.get('Id', ''))): + raise RuntimeError('INVALID_CREATE_ACK') + except Exception: + state['uncertain'] = True; self.save(state) + raise RuntimeError('UNCERTAIN_DOCKER_MUTATION') + state['mutation']['step'] = step.replace('_INTENT', '_DONE') + if step == 'CREATE_INTENT': state['createdId'] = result['Id'] + self.save(state) + return result + + def replace(self, state, current, desired, purpose): + if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + if self.container()['Id'] != current['Id']: raise RuntimeError('CONCURRENT_ORDER_CHANGE') + image = self.tools['docker']('GET', '/images/' + urllib.parse.quote(desired['Image'], safe='') + '/json') + if image['Id'] != state['originalImageId']: raise RuntimeError('IMAGE_TAG_MOVED_NO_RECREATE') + state['purpose'] = purpose; state['desiredBody'] = desired; state['replacingId'] = current['Id']; self.save(state) + if current['State']['Running']: + self.mutation(state, 'STOP_INTENT', 'POST', '/containers/' + current['Id'] + '/stop?t=15') + self.mutation(state, 'DELETE_INTENT', 'DELETE', '/containers/' + current['Id'] + '?v=false') + created = self.mutation(state, 'CREATE_INTENT', 'POST', '/containers/create?name=' + ORDER, desired) + self.mutation(state, 'START_INTENT', 'POST', '/containers/' + created['Id'] + '/start') + state['currentId'] = created['Id']; state['phase'] = purpose; state['mutation'] = None; self.save(state) + + def matches_injected(self, current, state): + expected = copy.deepcopy(state['originalBody']['Entrypoint']) + expected[2] += ''.join(' --' + prop + '=http://host.docker.internal:' + str(state['proxyPort']) + '/' + name.lower() + for name, prop in PROPERTIES.items()) + restored_view = copy.deepcopy(current) + restored_view['Config']['Entrypoint'] = state['originalBody']['Entrypoint'] + return (current['Image'] == state['originalImageId'] and current['Config']['Entrypoint'] == expected + and self.tools['fingerprint'](restored_view) == state['originalFingerprint']) + + def activate(self, state): + if state['phase'] != 'PROXY_STARTED' or state.get('uncertain'): raise RuntimeError('ACTIVATE_NOT_RETRYABLE') + if self.control(state)['mode'] != 'PASS': raise RuntimeError('PROXY_NOT_PASS') + current = self.container() + if current['Id'] != state['originalId'] or self.tools['fingerprint'](current) != state['originalFingerprint']: + raise RuntimeError('ORIGINAL_CONFIGURATION_CHANGED') + desired = copy.deepcopy(state['originalBody']) + desired['Entrypoint'][2] += ''.join(' --' + prop + '=http://host.docker.internal:' + str(state['proxyPort']) + '/' + name.lower() + for name, prop in PROPERTIES.items()) + self.replace(state, current, desired, 'INJECTED') + state['injectedFingerprint'] = self.tools['fingerprint'](self.container()); self.save(state) + return self.summary(state) + + def restore(self, state): + # PASS is attempted even if Docker state is ambiguous. Never stop the proxy here. + try: self.control(state, 'PASS') + except Exception: + state['passUnconfirmed'] = True; self.save(state) + if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + if state['phase'] == 'ABSENT_CONFIRMED': + try: self.tools['inspect'](ORDER) + except RuntimeError as error: + if str(error) != 'DOCKER_404': raise + else: raise RuntimeError('ORDER_REAPPEARED_NO_RECREATE') + body = copy.deepcopy(state['originalBody']) + image = self.tools['docker']('GET', '/images/' + urllib.parse.quote(body['Image'], safe='') + '/json') + if image['Id'] != state['originalImageId']: raise RuntimeError('IMAGE_TAG_MOVED_NO_RECREATE') + state['purpose'] = 'ORIGINAL_RESTORED'; state['desiredBody'] = body; self.save(state) + created = self.mutation(state, 'CREATE_INTENT', 'POST', '/containers/create?name=' + ORDER, body) + self.mutation(state, 'START_INTENT', 'POST', '/containers/' + created['Id'] + '/start') + state['currentId'] = created['Id']; state['phase'] = 'ORIGINAL_RESTORED'; state['mutation'] = None; self.save(state) + return self.summary(state) + current = self.container() + if self.tools['fingerprint'](current) == state['originalFingerprint'] and current['Image'] == state['originalImageId']: + if not current['State']['Running']: + state['purpose'] = 'ORIGINAL_RESTORED'; state['createdId'] = current['Id']; self.save(state) + self.mutation(state, 'START_INTENT', 'POST', '/containers/' + current['Id'] + '/start') + state['phase'] = 'ORIGINAL_RESTORED'; self.save(state); return self.summary(state) + if current['Id'] != state.get('currentId') or not self.matches_injected(current, state): + raise RuntimeError('UNRECOGNIZED_ORDER_CONFIGURATION') + self.replace(state, current, copy.deepcopy(state['originalBody']), 'ORIGINAL_RESTORED') + return self.summary(state) + + def reconcile(self, state): + # This action is intentionally read-only with respect to Docker. It never resumes deletion/creation. + if not state.get('uncertain'): return self.summary(state) + try: current = self.container() + except RuntimeError as error: + if str(error) != 'DOCKER_404' or (state.get('mutation') or {}).get('step') != 'DELETE_INTENT': + raise RuntimeError('MANUAL_DOCKER_RECOVERY_REQUIRED_NO_BLIND_RECREATE') + try: self.tools['inspect'](state['replacingId']) + except RuntimeError as by_id: + if str(by_id) != 'DOCKER_404': raise + else: raise RuntimeError('ORIGINAL_ID_STILL_PRESENT') + state['uncertain'] = False; state['phase'] = 'ABSENT_CONFIRMED'; state['mutation'] = None; self.save(state) + return self.summary(state) + step = (state.get('mutation') or {}).get('step') + original = current['Image'] == state['originalImageId'] and self.tools['fingerprint'](current) == state['originalFingerprint'] + injected = self.matches_injected(current, state) + desired_matches = injected if state.get('purpose') == 'INJECTED' else original + if step == 'STOP_INTENT' and current['Id'] == state.get('replacingId') and not current['State']['Running'] and (original or injected): + phase = 'ORIGINAL_RESTORED' if original else 'INJECTED' + elif step == 'CREATE_INTENT' and current['Id'] != state.get('replacingId') and desired_matches: + phase = state['purpose'] + elif step == 'START_INTENT' and current['Id'] == state.get('createdId') and current['State']['Running'] and desired_matches: + phase = state['purpose'] + else: + raise RuntimeError('MANUAL_DOCKER_RECOVERY_REQUIRED_NO_BLIND_RECREATE') + state['uncertain'] = False; state['currentId'] = current['Id']; state['phase'] = phase; state['mutation'] = None + if phase == 'INJECTED': state['injectedFingerprint'] = self.tools['fingerprint'](current) + self.save(state); return self.summary(state) + + def verify_restored(self, state): + current = self.container() + if state.get('uncertain') or current['Image'] != state['originalImageId'] or self.tools['fingerprint'](current) != state['originalFingerprint']: + raise RuntimeError('RESTORED_CONFIG_OR_IMAGE_MISMATCH') + if current['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('RESTORED_NOT_HEALTHY') + if self.config_fingerprint(current, state['routing']) != state['routingSourceSha256']: + raise RuntimeError('NACOS_ROUTE_CONFIGURATION_CHANGED') + checks = self.probes(state) + if checks != {'IAM': True, 'FINANCE': True}: raise RuntimeError('RESTORED_ROUTE_PROBE_INVALID') + state['routeReadsVerified'] = checks; self.save(state) + return {'healthy': True, 'originalFingerprint': state['originalFingerprint'], 'currentFingerprint': self.tools['fingerprint'](current), + 'originalImageId': state['originalImageId'], 'currentImageId': current['Image'], 'upstreamReads': checks, + 'nacosSourcesUnchanged': True} + + def finish(self, state, business): + self.verify_restored(state) + if self.lock.is_symlink() or self.lock.read_text() != self.scope['marker']: raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED') + if set(business or {}) != {'merchant', 'platform'} or any( + business[role].get('orderId') != order_id or business[role].get('status') != 'SUBMITTED' + or business[role].get('sameKeyReplayVerified') is not True for role, order_id in self.scope['orderIds'].items()): + raise RuntimeError('RESTORED_ORDER_ROUTES_NOT_PROVED') + pid = state.get('proxyPid') + if not pid and state.get('proxyLaunchIntent'): + pid_file = self.directory / 'proxy.pid' + if pid_file.is_symlink() or not pid_file.exists() or not pid_file.read_text().isdigit(): raise RuntimeError('PROXY_PID_UNCERTAIN') + pid = int(pid_file.read_text()) + command = Path('/proc/' + str(pid) + '/cmdline') if pid else None + if command and command.exists(): + if str(self.directory / 'proxy.py').encode() not in command.read_bytes().split(b'\0'): + raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') + os.kill(pid, signal.SIGTERM) + for _ in range(40): + if not command.exists() or not command.read_bytes(): break + time.sleep(0.1) + else: raise RuntimeError('PROXY_NOT_STOPPED') + state['restored'] = True; state['phase'] = 'FINISHED' + # Recovery snapshots remain protected until completion; erase secrets only after verified restoration. + for field in ['originalBody', 'desiredBody', 'internalToken', 'controlToken']: state.pop(field, None) + self.save(state) + config = self.directory / 'proxy-config.json' + if config.exists(): config.unlink() + self.lock.unlink() + return {'restored': True, 'healthy': True, 'proxyStopped': True, 'originalImageId': state['originalImageId'], + 'originalFingerprint': state['originalFingerprint'], 'orderRoutesVerified': True} + + +def execute(config): + runtime = Runtime(config['scope'], common_tools(base64.b64decode(config['commonSource']).decode())) + action = config['action'] + if action == 'preflight': + current = runtime.container() + return {'originalImageId': current['Image'], 'originalFingerprint': runtime.tools['fingerprint'](current), + 'sourceSha256': runtime.config_fingerprint(current, config['routing']), 'runtimeChanged': False} + if action == 'prepare': return runtime.prepare(config) + state = runtime.load() + if action == 'status': + result = runtime.summary(state) + if not state.get('restored'): + try: result['proxy'] = runtime.control(state) + except Exception: result['proxy'] = {'unavailable': True} + return result + if state.get('restored'): raise RuntimeError('ALREADY_FINISHED_USE_STATUS') + if action not in ('records', 'health') and (runtime.lock.is_symlink() or runtime.lock.read_text() != runtime.scope['marker']): + raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED') + if action == 'records': + path = runtime.directory / 'proxy-evidence.jsonl' + return {'records': [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []} + if action == 'activate': return runtime.activate(state) + if action == 'mode': + if state['phase'] != 'INJECTED' or state.get('uncertain'): raise RuntimeError('FAULT_WINDOW_NOT_READY') + return runtime.control(state, config['mode'], config.get('ttl', 0)) + if action == 'health': return {'healthy': runtime.container()['State'].get('Health', {}).get('Status') == 'healthy'} + if action == 'restore': return runtime.restore(state) + if action == 'reconcile': return runtime.reconcile(state) + if action == 'verify-restored': return runtime.verify_restored(state) + if action == 'finish': return runtime.finish(state, config.get('businessRecovery')) + raise RuntimeError('UNKNOWN_ACTION') + + +def main(config): + # A lost Jenkins response must not allow a second process to race the first Docker mutation. + import fcntl + validate_scope(config['scope']) + path = Path('/tmp') / ('chongchong-order-dependency-' + config['scope']['marker'] + '.action.lock') + if path.is_symlink(): raise RuntimeError('UNSAFE_ACTION_LOCK') + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + try: + try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: raise RuntimeError('RUNTIME_ACTION_IN_PROGRESS_USE_STATUS_LATER') + return execute(config) + finally: os.close(fd) + + +if __name__ == '__main__': + try: + result = main(json.loads(base64.b64decode(sys.argv[1]))) + print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': True, 'data': result})) + except Exception as error: + message = str(error) + print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED'})) -- 2.54.0 From c55bd7f4b666987037b90ab03586bcc78d147e59 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 06:21:57 +0800 Subject: [PATCH 076/176] fix(test): align order dependency fault preflight with Nacos v3 --- scripts/README.md | 2 +- ...order-submit-dependency-fault-executor.mjs | 2 +- ...-submit-dependency-fault-executor.test.mjs | 28 +++++++++++-- .../order-submit-dependency-fault-runtime.py | 39 ++++++++++++++----- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index f6e535b2f..d3a824463 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,7 @@ 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 -`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos 配置;`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 +`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 diff --git a/scripts/order-submit-dependency-fault-executor.mjs b/scripts/order-submit-dependency-fault-executor.mjs index 442037037..d433d6aa2 100644 --- a/scripts/order-submit-dependency-fault-executor.mjs +++ b/scripts/order-submit-dependency-fault-executor.mjs @@ -248,4 +248,4 @@ async function main(argv) { if(!result.success)process.exitCode=1 } -if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(()=>{console.error('ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1}) +if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1}) diff --git a/scripts/order-submit-dependency-fault-executor.test.mjs b/scripts/order-submit-dependency-fault-executor.test.mjs index 1e204dedf..048c34201 100644 --- a/scripts/order-submit-dependency-fault-executor.test.mjs +++ b/scripts/order-submit-dependency-fault-executor.test.mjs @@ -13,6 +13,21 @@ function fixture() { } const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'never-print-this',FBA_CORE_JENKINS_AGENT:'test-agent'} +test('runtime classifies dependency HTTP stages without exposing tokens, URLs or response bodies',()=>{ + const script=String.raw` +import importlib.util,sys,urllib.error +spec=importlib.util.spec_from_file_location('diagnostic_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +for path,expected in [('/nacos/v3/auth/user/login','NACOS_LOGIN_404'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_404'),('/internal/finance/wallet','DEPENDENCY_HTTP_404')]: + error=urllib.error.HTTPError('http://private.invalid'+path+'?accessToken=secret',404,'private response secret',{},None) + assert m.safe_error_code(error)==expected +assert m.safe_error_code(urllib.error.URLError('http://private.invalid?secret'))=='DEPENDENCY_TRANSPORT_FAILED' +assert m.safe_error_code(RuntimeError('KNOWN_FAILURE'))=='KNOWN_FAILURE' +assert m.safe_error_code(ValueError('secret in response'))=='ORDER_DEPENDENCY_ACTION_FAILED' +` + const result=spawnSync(process.env.PYTHON||'python',['-c',script,fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) + test('renderer binds TEST scope and emits no Jenkins credential; ambiguous remote mutation is never retried',async()=>{ const {plan}=fixture(),scope=scopeFor(plan) assert.ok(!renderOrderFaultAction('status',scope,env.FBA_CORE_JENKINS_AGENT).includes(env.JENKINS_API_TOKEN)) @@ -167,7 +182,7 @@ class RuntimeTests(unittest.TestCase): f,r,s,saved,controls=self.fixture() f.current['Config']['Env']+=['NACOS_SERVER_ADDR=host.docker.internal:8848','NACOS_USERNAME=offline-user', 'NACOS_PASSWORD=offline-password','NACOS_GROUP=BAIYUE','NACOS_NAMESPACE=test-only'] - routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v1/auth/users/login','configPath':'/nacos/v1/cs/configs'}} + routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v3/auth/user/login','configPath':'/nacos/v3/admin/cs/config'}} calls=[] class Response: status=200 @@ -177,12 +192,19 @@ class RuntimeTests(unittest.TestCase): def read(self):return self.value def read(req,timeout): calls.append(req) - return Response(b'{"accessToken":"offline-token"}' if not isinstance(req,str) else b'spring:\n test: only\n') + if req.data is not None:return Response(b'{"accessToken":"offline-token"}') + query=m.urllib.parse.parse_qs(m.urllib.parse.urlsplit(req.full_url).query) + self.assertEqual(req.get_method(),'GET');self.assertEqual(req.get_header('Authorization'),'Bearer offline-token') + self.assertNotIn('offline-token',req.full_url) + self.assertEqual(query['namespaceId'],['test-only']);self.assertEqual(query['groupName'],['BAIYUE']) + return Response(json.dumps({'code':0,'data':{'dataId':query['dataId'][0],'namespaceId':'test-only','groupName':'BAIYUE','content':'spring:\n test: only\n'}}).encode()) with patch.object(m,'http_open',side_effect=read): value=m.Runtime.config_fingerprint(r,f.current,routing) self.assertEqual(len(value),64);self.assertEqual(len(calls),3) self.assertIn(b'password=offline-password',calls[0].data) - self.assertTrue(all('accessToken=offline-token' in url for url in calls[1:])) + self.assertTrue(all('/nacos/v3/admin/cs/config?' in req.full_url for req in calls[1:])) + with patch.object(m,'http_open',return_value=Response(b'{"accessToken":"offline-token","code":0,"data":{"content":"secret config","dataId":"foreign"}}')): + with self.assertRaisesRegex(RuntimeError,'NACOS_SOURCE_IDENTITY_MISMATCH'):m.Runtime.config_fingerprint(r,f.current,routing) routing['nacos']['baseUrl']='http://other.invalid:8848' with self.assertRaisesRegex(RuntimeError,'EXPLICIT_NACOS'):m.Runtime.config_fingerprint(r,f.current,routing) self.assertEqual(len(calls),3) diff --git a/scripts/order-submit-dependency-fault-runtime.py b/scripts/order-submit-dependency-fault-runtime.py index 3823f2d2a..eb4014ec1 100644 --- a/scripts/order-submit-dependency-fault-runtime.py +++ b/scripts/order-submit-dependency-fault-runtime.py @@ -19,6 +19,7 @@ import sys import time import urllib.parse import urllib.request +import urllib.error from pathlib import Path ORDER = 'firstleg-test-order' @@ -139,7 +140,7 @@ class Runtime: return results def config_fingerprint(self, container, routing): - """Read the two actual Nacos imports using explicitly reviewed v1 endpoints. + """Read the two actual Nacos imports using the current v3 Admin endpoints. No endpoint guessing/fallback and no Nacos write operation is supported. """ env = self.tools['env_of'](container); config = routing.get('nacos', {}) @@ -150,8 +151,8 @@ class Runtime: network.get('Gateway') for network in container['NetworkSettings']['Networks'].values()} if (base.scheme not in ('http', 'https') or base.hostname not in allowed_hosts or base.port != configured.port or base.path not in ('', '/') or base.username or base.password or base.query or base.fragment - or config.get('loginPath') not in ('/nacos/v1/auth/users/login', '/nacos/v1/auth/login') - or config.get('configPath') != '/nacos/v1/cs/configs'): + or config.get('loginPath') != '/nacos/v3/auth/user/login' + or config.get('configPath') != '/nacos/v3/admin/cs/config'): raise RuntimeError('EXPLICIT_NACOS_READ_ENDPOINTS_REQUIRED') if any(not env.get(key) for key in ['NACOS_USERNAME', 'NACOS_PASSWORD', 'NACOS_GROUP', 'NACOS_NAMESPACE']): raise RuntimeError('NACOS_READ_IDENTITY_UNAVAILABLE') @@ -163,11 +164,20 @@ class Runtime: if not isinstance(token, str) or not token: raise RuntimeError('NACOS_READ_LOGIN_FAILED') fingerprints = {} for data_id in ('baiyue-common.yml', 'order-service.yml'): - query = urllib.parse.urlencode({'dataId': data_id, 'group': env['NACOS_GROUP'], 'tenant': env['NACOS_NAMESPACE'], 'accessToken': token}) - with http_open(origin + config['configPath'] + '?' + query, timeout=8) as response: + query = urllib.parse.urlencode({'dataId': data_id, 'groupName': env['NACOS_GROUP'], 'namespaceId': env['NACOS_NAMESPACE']}) + request = urllib.request.Request(origin + config['configPath'] + '?' + query, + headers={'Authorization': 'Bearer ' + token}) + with http_open(request, timeout=8) as response: if response.status != 200: raise RuntimeError('NACOS_SOURCE_READ_FAILED') - content = response.read() - if not content or content.lstrip().startswith((b'{', b'<')): raise RuntimeError('NACOS_SOURCE_NOT_YAML') + value = json.load(response) + data = value.get('data') + if value.get('code') != 0 or not isinstance(data, dict): raise RuntimeError('NACOS_SOURCE_RESPONSE_INVALID') + if (data.get('dataId') != data_id or data.get('groupName') != env['NACOS_GROUP'] + or data.get('namespaceId') != env['NACOS_NAMESPACE']): raise RuntimeError('NACOS_SOURCE_IDENTITY_MISMATCH') + content = data.get('content') + if not isinstance(content, str) or not content.strip() or content.lstrip().startswith(('{', '<')): + raise RuntimeError('NACOS_SOURCE_NOT_YAML') + content = content.encode('utf-8') fingerprints[data_id] = hashlib.sha256(content).hexdigest() return digest({'imports': fingerprints, 'namespace': env['NACOS_NAMESPACE'], 'group': env['NACOS_GROUP']}) @@ -436,10 +446,21 @@ def main(config): finally: os.close(fd) +def safe_error_code(error): + message = str(error) + if isinstance(error, urllib.error.HTTPError): + path = urllib.parse.urlsplit(error.geturl()).path + stage = 'NACOS_CONFIG' if path == '/nacos/v3/admin/cs/config' else ( + 'NACOS_LOGIN' if path == '/nacos/v3/auth/user/login' else 'DEPENDENCY_HTTP') + return stage + '_' + str(error.code) + if isinstance(error, urllib.error.URLError): + return 'DEPENDENCY_TRANSPORT_FAILED' + return message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED' + + if __name__ == '__main__': try: result = main(json.loads(base64.b64decode(sys.argv[1]))) print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': True, 'data': result})) except Exception as error: - message = str(error) - print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED'})) + print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': safe_error_code(error)})) -- 2.54.0 From d858357c5d59d1ca69ddde282338ee66a9119897 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 06:26:33 +0800 Subject: [PATCH 077/176] test(fba): preserve verified fleet and fulfillment acceptance workflows --- scripts/README.md | 20 +- scripts/acceptance-evidence-audit.mjs | 90 ++++ scripts/acceptance-evidence-audit.test.mjs | 53 +++ scripts/admin-test-env-acceptance.mjs | 2 +- scripts/admin-test-env-modules/README.md | 45 +- .../customer-system-contract.test.mjs | 23 + ...g-reservation-acceptance-contract.test.mjs | 109 +++++ .../fleet-booking-reservation-acceptance.mjs | 134 ++++++ ...rnal-dispatch-acceptance-contract.test.mjs | 90 ++++ .../fleet-external-dispatch-acceptance.mjs | 116 +++++ ...ernal-receipt-acceptance-contract.test.mjs | 150 +++++++ .../fleet-external-receipt-acceptance.mjs | 196 +++++++++ ...-fee-coverage-acceptance-contract.test.mjs | 108 +++++ .../fleet-fee-coverage-acceptance.mjs | 167 ++++++++ .../fleet-trip-fulfillment-contract.test.mjs | 273 ++++++++++++ .../fleet-trip-fulfillment.mjs | 296 +++++++++++++ .../manual-state-matrix-contract.test.mjs | 38 +- .../manual-state-matrix.mjs | 23 +- ...-inbound-status-fixtures-contract.test.mjs | 23 +- .../order-inbound-status-fixtures.mjs | 37 +- .../orders-session-contract.test.mjs | 30 ++ ...hase3-carrier-exceptions-contract.test.mjs | 102 +++++ .../phase3-carrier-schedule-queries.mjs | 93 ++++ .../phase3-order-splitting-contract.test.mjs | 17 + .../phase3-order-splitting.mjs | 8 +- ...-phase4-producer-fixture-contract.test.mjs | 2 + ...port-schedules-retention-contract.test.mjs | 12 +- .../phase3-port-schedules.mjs | 24 +- ...overseas-client-services-contract.test.mjs | 44 +- .../phase4-overseas-client-services.mjs | 97 ++++- ...phase4-overseas-dispatch-contract.test.mjs | 113 ++++- .../phase4-overseas-dispatch.mjs | 167 +++++++- ...se4-overseas-master-data-contract.test.mjs | 100 ++++- .../phase4-overseas-master-data.mjs | 27 +- ...-overseas-pda-operations-contract.test.mjs | 6 +- .../phase4-overseas-pda-operations.mjs | 10 +- .../phase5-overseas-fulfillment.mjs | 12 +- .../phase5-root-fulfillment-contract.test.mjs | 48 ++- .../phase5-root-fulfillment.mjs | 59 ++- .../phase5-root-tax-customs-contract.test.mjs | 69 +++ .../phase5-root-tax-customs.mjs | 97 +++++ scripts/admin-test-env-modules/system.mjs | 29 +- ...d-pda-session-revocation-contract.test.mjs | 68 +++ .../unified-pda-session-revocation.mjs | 77 ++++ .../admin-test-env-modules/vehicle-care.mjs | 102 +++++ scripts/fba-core-regression-test-env.mjs | 152 +++++++ scripts/fba-core-regression-test-env.test.mjs | 121 ++++++ scripts/fleet-test-env-acceptance.mjs | 399 ++++++++++++++++++ scripts/fleet-test-env-acceptance.test.mjs | 184 ++++++++ .../http-script-environment-contract.test.mjs | 5 +- scripts/mixed-container-outbound-test-env.mjs | 8 +- ...verseas-relabel-fulfillment-acceptance.mjs | 45 +- 52 files changed, 4217 insertions(+), 103 deletions(-) create mode 100644 scripts/acceptance-evidence-audit.mjs create mode 100644 scripts/acceptance-evidence-audit.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-external-receipt-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/orders-session-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase3-carrier-exceptions-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase5-root-tax-customs.mjs create mode 100644 scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/unified-pda-session-revocation.mjs create mode 100644 scripts/admin-test-env-modules/vehicle-care.mjs create mode 100644 scripts/fba-core-regression-test-env.mjs create mode 100644 scripts/fba-core-regression-test-env.test.mjs create mode 100644 scripts/fleet-test-env-acceptance.mjs create mode 100644 scripts/fleet-test-env-acceptance.test.mjs diff --git a/scripts/README.md b/scripts/README.md index d3a824463..a1fe8d6ad 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,24 @@ 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 +## 验收证据校验 + +`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 + `order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 + +`fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="" --env-file=""` 提供,数据库凭据只在执行节点内存读取。 + +该入口必需 `CHONGCHONG_ADMIN_API_BASE_URL/USERNAME/PASSWORD`(账号实际键为 `CHONGCHONG_ADMIN_USERNAME` 与 `CHONGCHONG_ADMIN_PASSWORD`)、`ADMIN_PDA_USERNAME/PASSWORD`、`FBA_CORE_TEST_GATEWAY_ORIGIN`、`JENKINS_URL/USER/API_TOKEN` 和 `FBA_CORE_JENKINS_AGENT`;TEST origin 与取证节点必须由操作者显式核准。可选客户账号密码成对提供,仍由正式链生成本轮测试客户。报告保存在 `reports/fba-core-/`,包括三模式报告、每次读取的独立证据及汇总;失败即停,不启动后续模式。文件和目录独占创建,不覆盖旧证据、不自动删数据或续跑;中断锁须核查原进程和现场后再处理。本专项不代表全部 F01–F40、页面或反向事件重复投递验收。 + +离线退税计划:`node scripts/fba-core-regression-test-env.mjs --plan --environment TEST --run-id <退税新批次> --tax-mode TAX_REFUND`;执行退税:`node --env-file="" --env-file="" scripts/fba-core-regression-test-env.mjs --execute --environment TEST --run-id <退税新批次> --tax-mode TAX_REFUND`。买单计划或执行省略 `--tax-mode`,或明确传 `BUY_DECLARATION`。不同税制使用各自新批次,目录防覆盖及失败即停规则相同。合同:`node --test scripts/fba-core-regression-test-env.test.mjs`。任何计划或离线合同通过均不代表三种退税顺序已在环境运行通过。 + +`acceptance-evidence-audit.mjs` 是本地只读证据检查入口,不连接环境、不需要凭据、不执行部署或数据库写入。`audit ` 按经过评审的需求清单逐项核对报告文件 SHA-256、业务域、证据级别、部署版本、运行标记、精确检查名称和 requirementId;HTTP/浏览器证据还须关联 requestId。失败、跳过、缺证据或矛盾结论均不能判完成。输出文件必须不存在,重跑使用新路径;保留旧证据、不自动清理。文件路径相对 manifest 所在目录。 + +Manifest 使用 `version:1, scope, revision, requirements:[{id,domain,level,checks,evidence:{path,sha256}}]`;报告使用相同 `revision`、`domain`、`level`(unit/contract/http/browser/device)、`runId`、`success` 与 `steps:[{name,requirementId,passed,requestId,marker}]`。需求清单应来自当前文档,不能按已有绿灯缩小清单。此检查只验证证据完整性和声明的一致性,不能自动证明业务断言语义正确,也不允许把旧报告批量加标签后冒充新运行。传统报告用 `check-legacy ` 检出相互冲突的完成字段;通过此模式仅代表无已识别字段矛盾,不代表业务验收通过。 + +离线回归:`node --test scripts/acceptance-evidence-audit.test.mjs`。命令失败返回1,参数/文件错误返回2;报告写入调用者指定的新路径。 + `event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 @@ -87,6 +104,8 @@ ADMIN_PDA_PASSWORD Admin 默认全量及依赖国内装柜链的定向验收必须提供 `ADMIN_PDA_USERNAME/PASSWORD`。管理员用于计划确认,PDA 作业员用于领取与执行;缺少作业员凭据在启动前阻断,不能以管理员身份代替已分配的作业员。 +默认全量、海外收货及人工状态矩阵还需显式注入 `JENKINS_URL/JENKINS_USER/JENKINS_API_TOKEN/FBA_CORE_JENKINS_AGENT`。总入口使用 `fba-core-regression-evidence.mjs`,在业务写入前预检 TEST 节点上 Order/Lastleg 容器与只读数据库连接;将收货完整快照和根/子完成 Outbox 计数接入本轮上下文。凭据不写入报告,不执行业务 SQL 写入;连接或取证失败继续阻断,不能使用旧报告代替当前快照。 + 脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境: ```powershell @@ -361,4 +380,3 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-executor.mjs` 和 `test-event-fault-runtime.py`,不是额外 CLI 入口。它们仅在显式 `CHONGCHONG_ENV=test` 和已授权环境中运行:在首个本轮计划完成前启动已有 ACK 丢失代理,短暂重建同镜像 Firstleg 容器,保留数据卷、端口和网络身份,只变更事件目标与本次故障退避参数。仅选定的装柜事件被抑制 ACK;其他事件直通。核对 12 次失败、DEAD、受保护原事件 retry、DUPLICATE 及业务订单零新增,结束或失败都恢复原镜像配置并检查健康,随后停止本任务代理。不修改业务 SQL、载荷、事件 ID 或 hash,不重放已发布事件。恢复失败必须保留阻断和任务元数据;独立后端发布或其他环境造数不得与此窗口并行。凭据仅在执行节点内存与权限600的临时恢复文件中使用,恢复完成后移除临时文件中的凭据。离线合同为 `test-event-fault-executor.test.mjs`。 `event-outbox-operations.mjs`:单测试环境的内部运维脚本。只连接显式指定的生产服务地址,不通过公网 Gateway,不自动重试或跟随重定向。凭据只从进程环境 `EVENT_OPERATIONS_TOKEN` 读取,另须 `CHONGCHONG_ENV=test`;不要把真实凭据放入参数或仓库。`--service firstleg|lastleg|finance --base <服务源地址> --entry ` 只读查询状态和摘要,不返回业务负载。追加 `--retry --request-id <稳定UUID> --operator <操作人> --reason <原因> --hash ` 会写入目标测试服务的 Outbox 与审计表,执行前须已有该环境操作授权。仅 DEAD 原事件可重试,事件身份、负载及摘要保持不变;重复提交使用同一个请求编号,参数变化会被拒绝。三个服务须先部署 `event_delivery_retry_audit` 表,未设置至少32字节的运维凭据时入口关闭。脚本将脱敏结果输出到标准输出,不持久化凭据;离线验证为 `node --test scripts/event-outbox-operations.test.mjs`。 - diff --git a/scripts/acceptance-evidence-audit.mjs b/scripts/acceptance-evidence-audit.mjs new file mode 100644 index 000000000..debed2934 --- /dev/null +++ b/scripts/acceptance-evidence-audit.mjs @@ -0,0 +1,90 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { resolve, dirname } from 'node:path' +import { pathToFileURL } from 'node:url' + +const levels = new Set(['unit', 'contract', 'http', 'browser', 'device']) +const digest = bytes => createHash('sha256').update(bytes).digest('hex') +const nonempty = value => typeof value === 'string' && value.trim().length > 0 + +// The reviewed manifest defines scope. Counts and free-text evidence never prove a requirement. +export function auditEvidence(manifest, load) { + if (!manifest || manifest.version !== 1 || !nonempty(manifest.scope) + || !nonempty(manifest.revision) || !Array.isArray(manifest.requirements) + || manifest.requirements.length === 0) throw new Error('Invalid requirement manifest') + const ids = new Set() + const results = manifest.requirements.map(requirement => { + const { id, domain, level, checks, evidence } = requirement + if (!nonempty(id) || ids.has(id) || !nonempty(domain) || !levels.has(level) + || !Array.isArray(checks) || checks.length === 0 || checks.some(x => !nonempty(x)) + || new Set(checks).size !== checks.length) throw new Error('Invalid or duplicate requirement') + ids.add(id) + const reasons = [] + if (!evidence || !nonempty(evidence.path) || !/^[a-f0-9]{64}$/.test(evidence.sha256 || '')) { + return { id, domain, level, passed: false, reasons: ['MISSING_EVIDENCE'] } + } + let report + try { + const bytes = load(evidence.path) + if (digest(bytes) !== evidence.sha256) reasons.push('HASH_MISMATCH') + report = JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, '')) + } catch { + return { id, domain, level, passed: false, reasons: ['UNREADABLE_EVIDENCE'] } + } + if (report.level !== level) reasons.push('EVIDENCE_LEVEL_MISMATCH') + if (report.domain !== domain) reasons.push('BUSINESS_DOMAIN_MISMATCH') + if (report.revision !== manifest.revision) reasons.push('REVISION_MISMATCH') + if (!nonempty(report.runId)) reasons.push('MISSING_RUN_ID') + if (!checkLegacyConsistency(report).passed) reasons.push('CONTRADICTORY_REPORT') + if (report.success !== true || report.fatal || report.completionClaim === false + || report.summary?.allConditionsComplete === false + || Number(report.summary?.failed || 0) > 0 + || Number(report.summary?.pendingConditions || 0) > 0 + || Number(report.summary?.skippedModules || 0) > 0) reasons.push('REPORT_NOT_COMPLETE') + if ((report.coverageGaps || []).some(gap => gap.blocking !== false) + || Number(report.summary?.coverageGaps?.blocking || 0) > 0 + || (report.deploymentBlockers || []).length > 0) reasons.push('BLOCKING_GAP') + const steps = Array.isArray(report.steps) ? report.steps : [] + if (steps.some(step => step.passed !== true || step.skipped)) { + reasons.push('FAILED_OR_SKIPPED_STEP') + } + for (const name of checks) { + const matches = steps.filter(step => step.name === name && step.requirementId === id) + if (matches.length !== 1 || matches[0].passed !== true) reasons.push(`CHECK_NOT_PROVEN:${name}`) + else if (['http', 'browser'].includes(level) && (!nonempty(matches[0].requestId) + || !nonempty(matches[0].marker) || matches[0].marker !== report.runId)) { + reasons.push(`MISSING_RUNTIME_IDENTITY:${name}`) + } + } + return { id, domain, level, passed: reasons.length === 0, reasons } + }) + const passed = results.filter(row => row.passed).length + return { version: 1, scope: manifest.scope, revision: manifest.revision, + generatedAt: new Date().toISOString(), completionClaim: passed === results.length, + summary: { requirements: results.length, passed, pending: results.length - passed }, results } +} + +export function checkLegacyConsistency(report) { + const reasons = [] + if (report.summary?.allConditionsComplete === true && report.completionClaim !== true) reasons.push('CONTRADICTORY_COMPLETION') + if (report.summary?.allConditionsComplete === true + && report.summary?.checkedScopeEvidence !== report.summary?.requirements) reasons.push('INCOMPLETE_CHECKED_SCOPE') + if (report.summary?.allConditionsComplete === true && (report.conditions || []).some(row => row.remaining)) reasons.push('REMAINING_REQUIREMENTS') + return { passed: reasons.length === 0, reasons, + scope: 'Consistency only; legacy report is not promoted to verified evidence' } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const [mode, input, output] = process.argv.slice(2) + if (!['audit', 'check-legacy'].includes(mode) || !input || !output) throw new Error('Usage: acceptance-evidence-audit.mjs audit|check-legacy ') + const source = JSON.parse(readFileSync(input, 'utf8').replace(/^\uFEFF/, '')) + const result = mode === 'audit' ? auditEvidence(source, file => readFileSync(resolve(dirname(input), file))) : checkLegacyConsistency(source) + writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' }) + console.log(JSON.stringify(result.summary || { passed: result.passed, reasons: result.reasons })) + process.exitCode = (mode === 'audit' ? result.completionClaim : result.passed) ? 0 : 1 + } catch (error) { + console.error(error.code === 'EEXIST' ? 'Output already exists; preserve prior evidence' : 'Evidence audit failed: invalid arguments, manifest or file') + process.exitCode = 2 + } +} diff --git a/scripts/acceptance-evidence-audit.test.mjs b/scripts/acceptance-evidence-audit.test.mjs new file mode 100644 index 000000000..ae72b84ea --- /dev/null +++ b/scripts/acceptance-evidence-audit.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { auditEvidence, checkLegacyConsistency } from './acceptance-evidence-audit.mjs' + +function fixture(change = {}) { + const report = { level: 'http', domain: 'carrier-schedule', revision: 'build-1', + runId: 'run-1', success: true, steps: [{ name: 'hold-preserves-normal-node', + requirementId: 'C66', passed: true, requestId: 'request-1', marker: 'run-1' }], ...change } + const bytes = Buffer.from(JSON.stringify(report)) + return { manifest: { version: 1, scope: 'carrier-exceptions', revision: 'build-1', requirements: [{ + id: 'C66', domain: 'carrier-schedule', level: 'http', checks: ['hold-preserves-normal-node'], + evidence: { path: 'report.json', sha256: createHash('sha256').update(bytes).digest('hex') }, + }] }, load: () => bytes } +} +test('exact required check, runtime identity, domain, level and revision pass together', () => { + const f = fixture(); assert.equal(auditEvidence(f.manifest, f.load).completionClaim, true) +}) +for (const [label, change, reason] of [ + ['unit test is not HTTP acceptance', { level: 'unit' }, 'EVIDENCE_LEVEL_MISMATCH'], + ['customs exception is not carrier exception', { domain: 'customs' }, 'BUSINESS_DOMAIN_MISMATCH'], + ['old deployment is not current deployment', { revision: 'old' }, 'REVISION_MISMATCH'], + ['explicit incomplete claim wins over success', { completionClaim: false }, 'REPORT_NOT_COMPLETE'], + ['nonzero failures block', { summary: { failed: 1 } }, 'REPORT_NOT_COMPLETE'], + ['partial evidence cannot claim full scope', { completionClaim: true, summary: { allConditionsComplete: true, checkedScopeEvidence: 65, requirements: 77 } }, 'CONTRADICTORY_REPORT'], + ['coverage blocker is never green', { coverageGaps: [{ blocking: true }] }, 'BLOCKING_GAP'], + ['skipped module is not complete', { summary: { skippedModules: 1 } }, 'REPORT_NOT_COMPLETE'], + ['request ID cannot be absent', { steps: [{ name: 'hold-preserves-normal-node', requirementId: 'C66', passed: true }] }, 'MISSING_RUNTIME_IDENTITY:hold-preserves-normal-node'], + ['unrelated green step is not matching evidence', { steps: [{ name: 'other', passed: true }] }, 'CHECK_NOT_PROVEN:hold-preserves-normal-node'], +]) test(label, () => { + const f = fixture(change), result = auditEvidence(f.manifest, f.load) + assert.equal(result.completionClaim, false) + assert.ok(result.results[0].reasons.includes(reason)) +}) +test('hash mismatch and nonexistent artifact fail closed', () => { + const f = fixture(); f.manifest.requirements[0].evidence.sha256 = '0'.repeat(64) + assert.ok(auditEvidence(f.manifest, f.load).results[0].reasons.includes('HASH_MISMATCH')) + assert.equal(auditEvidence(f.manifest, () => { throw new Error('missing') }).completionClaim, false) +}) +test('missing and duplicated requirements cannot silently reduce the scope', () => { + const f = fixture(); f.manifest.requirements.push(f.manifest.requirements[0]) + assert.throws(() => auditEvidence(f.manifest, f.load)) + f.manifest.requirements = []; assert.throws(() => auditEvidence(f.manifest, f.load)) +}) +test('duplicate matching steps cannot masquerade as one unambiguous proof', () => { + const f = fixture(), r = JSON.parse(f.load()); r.steps.push(r.steps[0]) + const changed = fixture({ steps: r.steps }); assert.equal(auditEvidence(changed.manifest, changed.load).completionClaim, false) +}) +test('September r7 contradiction is rejected without rewriting original evidence', () => { + assert.deepEqual(checkLegacyConsistency({ completionClaim: false, summary: { + allConditionsComplete: true, checkedScopeEvidence: 65, requirements: 77, + } }).reasons, ['CONTRADICTORY_COMPLETION', 'INCOMPLETE_CHECKED_SCOPE']) +}) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index d1232dc94..1cce9dd2c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -77,7 +77,7 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-order-splitting', ['phase3-loading-finalize']], - ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents']], + ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents', 'phase5-overseas-fulfillment']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], ['phase4-overseas-pda', ['phase4-tail-order-controls']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 0b1a0c642..9950d8bab 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,14 +1,49 @@ # Admin 测试环境 HTTP 模块 +`TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 + +`EXTERNAL_RECEIPT_CONFIRMED` 仅接受原运营实收确认明确成功、后续只读/重放断言失败且尚未开始排车关联的样本。先核对原来货已可排车、实际容量及单一库存流水,再原键原载荷重放实收确认;不重新入库。重放比较以数据库持久化后的完整预订为基准,避免直接拿命令返回的纳秒 `updatedAt` 与数据库毫秒精度比较;货量、报价、版本及全部业务字段仍精确核对。实收总箱数字段为正式 `receivedPackages`。 + +`EXTERNAL_PALLETIZED` 仅用于原 PDA 入库命令明确成功、库存只读核对失败且尚未运营确认的原样本。要求保存原请求键、实托和本轮临时 PDA 账号,回查原容量预留、来货状态及账号仓绑定后,只重放原入库命令,不重新创建或收货;再继续运营核量。库存列表是分组汇总,来源归属须读取该组引用的余额详情及实物位置,并与本票不可变流水核对;不能假定汇总行包含来货码。每次显式续跑保存独立报告和原报告哈希,保留首次失败证据。 + +`EXTERNAL_RECEIPT_READY` 是另一处显式复核检查点:仅全/部分费用覆盖与占位均已验证、PDA账号创建明确因手机号冲突被拒绝、尚未创建外接来货时允许继续。继续前回查原车次分配、客户排车及未拣/未发事实;新测试PDA使用独立标识,不复用恢复前的联系电话生成序号。已有外接实收/关联或账号写入结果未知时不得使用此检查点。 + +若全覆盖命令已经返回成功、但后续只读核对失败,可在核对原出库/派送确实已保存且未执行后,显式使用 `--resume-stage FULL_COVERAGE_PERSISTED`。此模式要求原成功命令、出库号和完整派送集合,先查正式详情,再仅重放原键原载荷;不重新运行创建前的负向写用例,不更换请求键或重造业务单据。目的地以当前 `destinationWarehouseCode` 优先、`fbaWarehouseCode` 兜底,与车次及派送服务一致,不能用原始 FBA 快照否定已经变更的实际目的地。 + +车辆专项修复后可显式选择已复核的只读检查点:`--resume-report reports/<原批次>/report.json --resume-stage FULL_COVERAGE`,仍须新的 `--run-id`。只接受全覆盖阶段仅执行过 GET、尚无出库且无任何 POST 的失败记录;复核同一车次资源、排车货物及未拣/未发事实后继续原样本,原失败报告不覆盖,保留其哈希与旧检查记录。其他阶段及写入结果未知均拒绝自动续跑,须先按原命令核对实际结果。出库列表仅使用正式客户范围分页,再逐行匹配排车号;不假定该列表支持 `dispatchNo` 查询过滤。 + +车辆专项入口为 `../fleet-test-env-acceptance.mjs`,先使用原 `phase4-overseas-dispatch` 依赖链创建本轮客户、头程费用和海外库存,再执行专用车辆编排;不改变已通过的 F40 默认调用顺序。默认 `--plan` 无网络,实际运行须显式 `--execute --environment TEST --run-id <新批次>`。仅串行运行,会创建当前批次资源及执行正式未执行单据取消重排,不执行部署、配置修改或业务 SQL 写入;原收货取证沿用现有只读读取器。失败保留原样本、请求与报告,不自动重造或续跑。 + +四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 用独立排车验证全覆盖及多原入仓的部分覆盖,核对原费用版本、目的地、快照及重放不重复收费;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。 + 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 + +`fleet-trip-fulfillment.mjs` 是内部车次验收助手,不是独立入口。五期与F40模块通过正式API建立本轮车辆容量、测试司机及PNG证照上传/审核、车次关联、目的站匹配及本单调度审核通过;拣货后确认明确标记为测试的实物测量,通过整车理货审核后携带最新车次版本统一发车。资源及文件ID保留到报告,不自动删除、不以SQL修改状态;失败停止,不能跳过资格或装载。`mixed-container-outbound-test-env.mjs` 与 `overseas-relabel-fulfillment-acceptance.mjs` 的留存场景须在拣货前已有正式车次关联和通过的调度审核,不能对已经开工的旧单补造关联;它们确认受控测试装载、理货审核并继续履约。改派检查保留拣货事实,但重新通过每张单的调度审核后再确认装载。`fleet-trip-fulfillment-contract.test.mjs` 离线验证重复实托、目的地及准备顺序,不代表HTTP验收。 + +`prepareFleetTrip` 创建的本轮车次必须完成发车前改派验证:`confirmFleetLoading` 先确认装载,再创建等容量替换车辆和经文件上传/审核的替班司机,经正式分配接口改派。回查客户关联、原货物、拣货数量、路线和到达时间未改变,原装载确认已清除;旧版本确认须被拒绝且不产生写入,最后按新版本重新确认。返回最终版本供既有 PDA 就绪检查和整车发车使用。报告 `createdResources.fleetTrips[].reassignment` 留存前后快照、替换资源、原请求和结果,只有全部通过才标记 `VERIFIED`;失败保留 `CHECKING`,同一证明对象禁止自动重试或重造。顶层资源引用随后更新为替换资源供结案/释放验证,原资源仍保留在快照中。留存场景不执行这段新建资源与改派流程。 + +五期和F40签收后继续调用 `finishFleetTrip`:正式结案并同键重放,通过两个无货计划及一辆隔离用测试车辆验证“结案仍占用车辆、只结束车辆仍占用司机、两项结束后可重新分配、旧结束请求重放不能清除新占用”。仅409不足以通过,必须是相应资源占用冲突;全部完成后以正式取消命令取消无货计划并保留历史。测试车辆/司机在末站结束,不含返程任务;地点、时间、原请求及计划号保存于 `createdResources.fleetTrips[].closure`。失败保留CHECKING及原资源,不自动重造或修复旧fixture;离线模拟通过不等于这些真实HTTP场景已执行。 + `phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 `phase3-loading.mjs` 的 PDA 原子证据上传在未取得 HTTP 响应时最多尝试三次,冻结原 URL、作业员身份、幂等键、文件名/内容、证据版本和封签号;不重试 HTTP/业务拒绝或已收到响应后的读取失败。`createdResources.loadingEvidenceTransport` 保存各次结果及白名单网络错误码,不保存凭据或原始错误对象。`phase3-evidence-transport-contract.test.mjs` 用持久幂等模型验证响应丢失重放仅一条证据、持续传输失败有界终止及拒绝不重试;离线模型不替代真实 HTTP。 +`orders.mjs` 每次运行优先使用当前 `merchantQualificationFixture` 的完整账号对,其次为明确配置;不在模块 import 时缓存客户凭据。当前 fixture 不完整时停止,不与上一轮或环境账号拼接。`orders-session-contract.test.mjs` 在同进程重复调用真实订单登录入口,覆盖连续客户隔离,防止多模式串行时沿用前一轮身份。 + +`phase4-overseas-pda.mjs` 的收货入口对本轮 `containerBookingId + containerNo` 做有界候选收敛等待(最多 30 次、间隔 1 秒),只有唯一且可操作的原柜才继续;鉴权失败、畸形响应和重复候选立即阻断。它用于等待异步船节点投影,不构成事件重放证据。F40 在成功和失败时均保留 `receivingCandidateEvidence` 的逐次查询身份、结果和诊断信息,不覆盖已有收货会话,不借其他柜或历史柜继续。 + +多目的地排车 C52:`phase4-overseas-dispatch.mjs` 在本轮实际收货产生的同仓同客户托盘上验证多 FBA 目的地及重复目的地托盘的增加、移除、重放和拒绝后零漂移。路线期望从实际排车明细及本轮冻结候选独立推导,记录 `routeLifecycle`,最终恢复原主链排车并释放额外测试托盘;缺少至少两个目的地及同目的地双托前置时明确阻断,不借历史数据或用单目的地代替。复用 candidates、draft-locks/lock、pre-arrange、详情和 DELETE items 正式 API;定向入口仍为 `--modules phase4-overseas-dispatch`,会写 TEST,使用新报告路径,失败保留样本,不自动删除重跑。合同为 `phase4-overseas-dispatch-contract.test.mjs`;离线成功不代表实际 HTTP 生命周期通过。 + +船节点异常 C66:`phase3-carrier-schedule-queries.mjs` 的 `captureCarrierExceptionEvidence` 与 `verifyCarrierExceptionTransition` 对本轮原船任务采集只读 checkpoint,核对 CUSTOMS_HOLD/TERMINAL_HOLD/DOCUMENT_HOLD/UNKNOWN_HOLD 的追加、解除、重放及冲突前后正常实际/预计节点守恒。`phase3-carrier-exceptions-contract.test.mjs` 只验证离线比对逻辑;真实生命周期还需要 Integration 供应商结果经调度器消费的独立投递证据。默认 HTTP 链只采基线并保留该覆盖缺口;空异常数组、重复 GET、报关异常和合成快照均不能计为供应商异常生命周期通过。 + +对应本地数据库事务测试为 Firstleg `CarrierScheduleExceptionPersistenceTest`,使用真实 Service/Repository/MyBatis 和 H2,验证四类异常及解除的同事件重放、异摘要冲突、正常实际/预计节点整行不变,以及异常历史写入失败时事务回滚。它属于本地持久化验证,不代替 TEST MySQL 或真实供应商调度链验收。 + `phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 +默认税制为 `BUY_DECLARATION`;编程调用显式设置 `AcceptanceRun.context.f40TaxMode='TAX_REFUND'` 可执行退税链。内部模块 `phase5-root-tax-customs.mjs` 在每个实装柜走客户上传、平台回执、客户确认、发送执行、人工正式申报与放行、完成文件,再进入尾程;订单最终完成后回查本客户资料并校验原件、回执、完成文件的实际下载字节和 SHA-256。该模块不能独立执行,不用 SQL 修改状态,不借历史资料;依赖相同 TEST 授权、当轮客户与只读取证器,失败及重跑保留各轮原始报告。直接合同为 `phase5-root-tax-customs-contract.test.mjs`,离线通过不代替实际 HTTP 或页面验收。 + `AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原键原版本原载荷取消重放成功;同键异载荷及新键重复取消返回 `A0409`,根子版本、历史、摘要、履约树及完成 Outbox 均不漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。 会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。 @@ -135,6 +170,10 @@ export async function run(context) { ## 约束 +`unified-pda-session-revocation.mjs` 是海外库内作业模块的内部安全检查;在本轮操作员完成库存作业后修改其作业仓,分别验证旧 Access/Refresh 失效、新登录只绑定新仓、角色及仓绑定清空后旧会话失效且不能继续 PDA 作业。只更新本轮临时账号并由原 finally 停用,凭据保持在私有上下文;不改仓库主档或业务库存。合同为 `unified-pda-session-revocation-contract.test.mjs`。 + +`direct-delivery-replay-proof.mjs` 是事件模块的内部证据校验器,不是独立执行入口。要求本轮原装柜事件的 ID/hash、12 次失败至 DEAD、人工恢复审计、持久 Inbox 及 DUPLICATE、恢复前后业务订单身份集合零新增和环境恢复同时成立;单纯重复 GET 或历史故障报告不能满足条件。离线合同为 `direct-delivery-replay-proof-contract.test.mjs`。 + - 不记录密码、Token、图片二进制或 OCR 原文全文。 - 测试数据必须带 `context.marker`;可删除的清理,不能删除的最终停用。 - 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。 @@ -206,9 +245,9 @@ export async function run(context) { | 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(统一平台会话、匿名拒绝、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询与诊断证据;重复 GET 仅验证读取稳定,缺真实投递重放证据仍阻断;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | | 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 | -| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | +| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 | | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | -| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | +| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` | | 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | | 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | @@ -357,5 +396,3 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra 国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。 `event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。 - -`direct-delivery-replay-proof.mjs` 是事件模块的内部证据校验器,不是独立执行入口。要求本轮原装柜事件的 ID/hash、12 次失败至 DEAD、人工恢复审计、持久 Inbox 及 DUPLICATE、恢复前后业务订单身份集合零新增和环境恢复同时成立;单纯重复 GET 或历史故障报告不能满足条件。离线合同为 `direct-delivery-replay-proof-contract.test.mjs`。 diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 939156479..f8334d4ab 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -1,9 +1,32 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { verifyRevokedSessions } from './system.mjs' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +test('撤销检查逐会话核对Access和Refresh且403不能冒充会话失效', async () => { + const records = [], calls = [] + const context = { + assert: (_module, name, _response, passed, evidence) => records.push({ name, passed, evidence }), + request: async (method, path, body, token) => { + calls.push({ method, path, body, token }) + return { http: path.endsWith('/refresh') ? 200 : 401, body: { code: 'A0401' } } + }, + } + await verifyRevokedSessions(context, '密码重置', [{ accessToken: 'a-1', refreshToken: 'r-1' }, + { accessToken: 'a-2', refreshToken: 'r-2' }]) + assert.equal(calls.length, 4) + assert.ok(records.every(row => row.passed)) + assert.ok(records.every(row => !/a-1|r-1|a-2|r-2/.test(row.evidence))) + assert.equal(calls[1].token, '') + context.request = async () => ({ http: 403, body: { code: 'A0403' } }) + await verifyRevokedSessions(context, '停用', [{ accessToken: 'a', refreshToken: 'r' }]) + assert.equal(records.at(-2).passed, false) + assert.equal(records.at(-1).passed, false) + await assert.rejects(verifyRevokedSessions(context, '缺失', [{}]), /完整 Access\/Refresh/) +}) + const here = path.dirname(fileURLToPath(import.meta.url)) const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8') const system = fs.readFileSync(path.join(here, 'system.mjs'), 'utf8') diff --git a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs new file mode 100644 index 000000000..d96efd803 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { verifyFleetBookingReservation } from './fleet-booking-reservation-acceptance.mjs' + +function fixture(fault = '') { + const baseline = [ + { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 }, remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } }, + { fromIndex: 1, toIndex: 2, occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 }, remaining: { palletSpaces: 29, weightKg: 17900, volumeCbm: 89 } }, + ] + const cargo = [ + { dispatchNo: 'D-TEST', customerId: '900719925474099301', warehousePalletId: '900719925474099311', palletNo: 'P-A', destinationKey: 'FBA:A' }, + { dispatchNo: 'D-TEST', customerId: '900719925474099301', warehousePalletId: '900719925474099312', palletNo: 'P-B', destinationKey: 'FBA:B' }, + ] + const tripProof = { tripNo: 'T-TEST', dispatchNo: 'D-TEST', vehicleId: 'V-TEST', driverId: 'DR-TEST', + stops: [{ name: '起点' }, { name: 'A', destinationKey: 'FBA:A' }, { name: 'B', destinationKey: 'FBA:B' }] } + let tripVersion = 10, planned = false, segments = structuredClone(baseline) + const bookings = [], bodies = new Map(), calls = [], assertions = [] + const response = (data, rejected = false) => ({ http: 200, body: { code: rejected ? 'A0409' : '200', data, + message: fault === 'wrong-conflict' ? '版本已变化' : '第1段路线超过车辆托位、重量或容积限制' }, + requestId: 'test-request', traceId: fault === 'missing-trace' ? null : 'test-trace' }) + const result = (booking, replayed = false) => response({ tripRecordVersion: tripVersion, booking: structuredClone(booking), replayed }) + const context = { marker: 'OFFLINE-CONTRACT', createdResources: { fleetTrips: [tripProof] }, + assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, + async request(method, path, body) { + const suffix = path.replace('/api/platform/v1/last-leg/fleet/trips/T-TEST', '') + calls.push({ method, suffix, body }) + if (method === 'GET' && suffix === '') return response({ trip: { status: 'PLANNED', vehicleId: tripProof.vehicleId, driverId: tripProof.driverId }, dispatches: [{ dispatchNo: 'D-TEST' }] }) + if (method === 'GET' && suffix === '/loading') return response({ confirmedLoad: fault === 'fake-loaded' ? {} : null }) + if (suffix === '/capacity-plan') { + if (method === 'POST') { planned = true; tripVersion++ } + return response({ recordVersion: tripVersion, cargo, manifestHash: 'a'.repeat(64), blockReason: null, + capacityPlan: planned ? { manifestHash: 'a'.repeat(64) } : null, + planValid: planned, availableSegments: structuredClone(segments) }) + } + if (method === 'GET' && suffix === '/bookings') return response({ tripRecordVersion: tripVersion, records: structuredClone(bookings) }) + if (method === 'POST' && suffix === '/bookings') { + if (bodies.has(body.idempotencyKey)) { + const old = bodies.get(body.idempotencyKey) + if (JSON.stringify(old.body) !== JSON.stringify(body)) return response(null, true) + return result(old.booking, true) + } + assert.equal(body.tripRecordVersion, tripVersion) + const booking = { bookingNo: `FB-${bookings.length + 1}`, tripNo: tripProof.tripNo, customerId: body.customerId, + cargo: body.cargo, status: 'DRAFT', recordVersion: 0 } + bookings.push(booking); bodies.set(body.idempotencyKey, { body, booking }); tripVersion++ + return result(booking) + } + const match = suffix.match(/^\/bookings\/(FB-\d+)\/(confirm|cancel)$/) + assert.ok(match, `unexpected ${method} ${suffix}`) + const booking = bookings.find(row => row.bookingNo === match[1]) + if (bodies.has(body.idempotencyKey)) { + if (fault === 'replay-reoccupies' && match[2] === 'confirm') segments[0].remaining.palletSpaces-- + return result(booking, true) + } + assert.equal(body.tripRecordVersion, tripVersion) + assert.equal(body.recordVersion, booking.recordVersion) + if (match[2] === 'confirm') { + if (booking.cargo.palletSpaces > segments[0].remaining.palletSpaces) return response(null, true) + booking.status = 'CONFIRMED'; booking.agreedAmount = 25 + for (const index of (fault === 'wrong-segment' ? [0, 1] : [0])) { + segments[index].occupied.palletSpaces++; segments[index].occupied.weightKg++ + segments[index].occupied.volumeCbm += 0.01 + segments[index].remaining.palletSpaces--; segments[index].remaining.weightKg-- + segments[index].remaining.volumeCbm -= 0.01 + } + } else { + if (booking.status === 'CONFIRMED' && fault !== 'cancel-does-not-release') segments = structuredClone(baseline) + booking.status = 'CANCELLED' + } + tripVersion++; booking.recordVersion++; bodies.set(body.idempotencyKey, { body, booking }) + return result(booking) + }, + } + const measurements = (actual, stops) => { + assert.deepEqual(actual, cargo); assert.deepEqual(stops, tripProof.stops) + return cargo.map((row, index) => ({ warehousePalletId: row.warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: index + 1 })) + } + return { context, tripProof, measurements, calls, bookings, assertions } +} + +test('reservation HTTP helper verifies actual segments, original requests and cancellation without receiving or charging', async () => { + const f = fixture() + const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements) + assert.equal(proof.status, 'VERIFIED') + assert.equal(proof.bookings.length, 2) + assert.ok(f.bookings.every(row => row.status === 'CANCELLED')) + assert.deepEqual(proof.finalSegments, proof.initialSegments) + assert.ok(proof.requests.every(row => row.requestId && row.traceId)) + assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) + assert.ok(f.calls.every(row => !/incoming|receipt-confirmation|transfer-delivery|departure/.test(row.suffix))) + assert.equal(proof.createRequest.customerId, '900719925474099301') + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements), /本轮新建/) +}) + +for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-release', 'fake-loaded', 'missing-trace', 'wrong-conflict']) { + test(`reservation helper fails closed on ${fault} even when HTTP reports success`, async () => { + const f = fixture(fault) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements)) + assert.equal(f.tripProof.reservation.status, 'CHECKING') + assert.ok(f.assertions.some(row => !row.passed && row.options.deploymentBlocker)) + if (['wrong-segment', 'replay-reoccupies'].includes(fault)) assert.equal(f.calls.some(row => row.suffix.endsWith('/cancel')), false) + }) +} + +test('reservation helper rejects foreign fixture before making any request', async () => { + const f = fixture() + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }, f.measurements), /本轮新建/) + assert.equal(f.calls.length, 0) +}) diff --git a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs new file mode 100644 index 000000000..ff8651c1e --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto' + +const OPTIONS = { scenario: 'fleet-external-reservation', deploymentBlocker: true } +const text = value => value == null ? '' : String(value) +const success = response => response?.http === 200 && text(response?.body?.code) === '200' +const conflict = response => response?.http === 409 || (response?.http === 200 && text(response?.body?.code) === 'A0409') +const key = () => `fleet-reservation-${randomUUID()}` +const stable = value => JSON.stringify(value) +const version = value => /^(0|[1-9]\d*)$/.test(text(value)) +const trace = response => ({ http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + +/** Internal HTTP helper. Operates only on the current run's trip before picking; no SQL or cleanup on failure. */ +export async function verifyFleetBookingReservation(context, module, tripProof, measurements) { + if (!context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof.tripNo || tripProof.reservation) + throw new Error('外接占位验收仅接受本轮新建且尚未开始验收的车次;失败后保留原证据') + const proof = tripProof.reservation = { status: 'CHECKING', marker: context.marker, requests: [], bookings: [] } + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}` + async function check(name, passed, response, evidence = '') { + context.assert(module, name, response, Boolean(passed), evidence, OPTIONS) + if (!passed) throw new Error(`${module}: ${name}`) + } + async function request(method, suffix, body, label, expectedConflict = false) { + const response = await context.request(method, `${base}${suffix}`, body) + proof.requests.push({ method, suffix, label, ...trace(response) }) + const expectedResult = expectedConflict ? conflict(response) + && (!(expectedConflict instanceof RegExp) || expectedConflict.test(text(response?.body?.message))) : success(response) + await check(label, expectedResult + && Boolean(response?.requestId) && Boolean(response?.traceId), response, JSON.stringify(trace(response))) + return response.body?.data + } + const detail = await request('GET', '', undefined, '读取本轮车次与货物归属') + await check('占位验收保留当前车次及分配资源', detail?.trip?.status === 'PLANNED' + && text(detail.trip.vehicleId) === text(tripProof.vehicleId) && text(detail.trip.driverId) === text(tripProof.driverId) + && detail.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)) + const initial = await request('GET', '/capacity-plan', undefined, '读取拣货前真实自有货容量依据') + await check('自有货容量来源完整', initial?.cargo?.length > 0 && !initial.blockReason + && initial.cargo.every(row => row.customerId && row.warehousePalletId && row.destinationKey) + && version(initial.recordVersion) && /^[0-9a-f]{64}$/.test(initial.manifestHash || '')) + const pallets = measurements(initial.cargo, tripProof.stops) + const plan = await request('POST', '/capacity-plan', { + recordVersion: initial.recordVersion, manifestHash: initial.manifestHash, pallets, + reason: '受控测试计划:按本轮真实实托确认预计占用,非生产测量', + }, '确认本轮自有货计划容量') + await check('保存容量计划不冒充实物装载', plan?.planValid === true + && plan.capacityPlan?.manifestHash === initial.manifestHash && plan.availableSegments?.length === tripProof.stops.length - 1) + const loading = await request('GET', '/loading', undefined, '回查计划容量没有生成实载确认') + await check('实载确认仍待执行', loading?.confirmedLoad == null) + proof.initialSegments = plan.availableSegments + const destinationKey = initial.cargo[0].destinationKey + const destinationIndex = tripProof.stops.findIndex(stop => stop.destinationKey === destinationKey) + const affected = plan.availableSegments.filter(row => row.fromIndex < destinationIndex) + await check('本轮测试车次确有可销售余位', destinationIndex > 0 && affected.length === destinationIndex + && affected.every(row => Number(row.remaining?.palletSpaces) >= 1 && Number(row.remaining?.weightKg) >= 1 + && Number(row.remaining?.volumeCbm) >= 0.01)) + let tripVersion = plan.recordVersion + const draftBody = { tripRecordVersion: tripVersion, customerId: text(initial.cargo[0].customerId), + idempotencyKey: key(), cargo: { destinationKey, cargoName: `${context.marker} 外接占位验收`, + packageCount: 1, palletSpaces: 1, weightKg: 1, volumeCbm: 0.01, + priceBasis: 'PALLET', unitPrice: 25, currency: 'CNY', remark: '仅验证询价及承运占位;不创建来货、不写库存或财务' } } + proof.createRequest = draftBody + const created = await request('POST', '/bookings', draftBody, '登记本轮外接询价') + await check('外接询价正确归属本车客户及目的地', created?.booking?.status === 'DRAFT' + && created.booking.tripNo === tripProof.tripNo && created.booking.customerId === draftBody.customerId + && created.booking.cargo?.destinationKey === destinationKey && created.booking.bookingNo) + proof.bookings.push(created.booking.bookingNo) + const number = encodeURIComponent(created.booking.bookingNo) + tripVersion = created.tripRecordVersion + const replay = await request('POST', '/bookings', draftBody, '重放相同外接登记请求') + await check('登记重放没有新预订或版本推进', replay?.replayed === true && replay.booking?.bookingNo === created.booking.bookingNo + && text(replay.tripRecordVersion) === text(tripVersion) && text(replay.booking?.recordVersion) === text(created.booking.recordVersion)) + await request('POST', '/bookings', { ...draftBody, cargo: { ...draftBody.cargo, packageCount: 2 } }, '相同请求键改变货量被拒绝', true) + const draftCapacity = await request('GET', '/capacity-plan', undefined, '回查询价与错误重试后的余位') + await check('询价及错误重试没有占位', stable(draftCapacity.availableSegments) === stable(proof.initialSegments) + && text(draftCapacity.recordVersion) === text(tripVersion)) + const confirmBody = { tripRecordVersion: tripVersion, recordVersion: created.booking.recordVersion, idempotencyKey: key() } + proof.confirmRequest = confirmBody + const confirmed = await request('POST', `/bookings/${number}/confirm`, confirmBody, '确认本轮外接承运') + await check('承运确认保持约定报价且不假收货', confirmed?.booking?.status === 'CONFIRMED' + && Number(confirmed.booking.agreedAmount) === 25 && !confirmed.booking.incomingNo && !confirmed.booking.dispatchNo) + tripVersion = confirmed.tripRecordVersion + const reserved = await request('GET', '/capacity-plan', undefined, '读取确认承运后的分段余位') + const expected = proof.initialSegments.map(segment => { + if (segment.fromIndex >= destinationIndex) return segment + const sum = (amount, delta) => Math.round((Number(amount) + delta) * 1000) / 1000 + return { ...segment, + occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + 1, + weightKg: sum(segment.occupied.weightKg, 1), volumeCbm: sum(segment.occupied.volumeCbm, 0.01) }, + remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - 1, + weightKg: sum(segment.remaining.weightKg, -1), volumeCbm: sum(segment.remaining.volumeCbm, -0.01) } } + }) + await check('只在起运至约定卸货站逐段扣减一次', stable(reserved.availableSegments) === stable(expected) + && reserved.planValid === true && text(reserved.recordVersion) === text(tripVersion)) + proof.reservedSegments = reserved.availableSegments + const confirmReplay = await request('POST', `/bookings/${number}/confirm`, confirmBody, '重放承运确认') + const afterReplay = await request('GET', '/capacity-plan', undefined, '核对承运重放后的容量') + await check('重复承运确认不二次占位', confirmReplay?.replayed === true + && text(confirmReplay.tripRecordVersion) === text(tripVersion) + && text(afterReplay.recordVersion) === text(tripVersion) && stable(afterReplay.availableSegments) === stable(expected)) + const availableSpaces = Math.min(...reserved.availableSegments.filter(row => row.fromIndex < destinationIndex).map(row => Number(row.remaining.palletSpaces))) + const excess = await request('POST', '/bookings', { ...draftBody, tripRecordVersion: tripVersion, idempotencyKey: key(), + cargo: { ...draftBody.cargo, palletSpaces: availableSpaces + 1 } }, '登记超出余位但未确认的第二条询价') + await check('第二条仍为询价', excess?.booking?.status === 'DRAFT' && excess.booking.bookingNo !== created.booking.bookingNo) + proof.bookings.push(excess.booking.bookingNo) + tripVersion = excess.tripRecordVersion + await request('POST', `/bookings/${encodeURIComponent(excess.booking.bookingNo)}/confirm`, { + tripRecordVersion: tripVersion, recordVersion: excess.booking.recordVersion, idempotencyKey: key(), + }, '超卖确认被拒绝', /超过车辆托位、重量或容积限制/) + const rejected = await request('GET', '/bookings', undefined, '回查超卖拒绝后的预订状态') + const rejectedCapacity = await request('GET', '/capacity-plan', undefined, '回查超卖拒绝后的分段余位') + await check('超卖失败保持询价和已承诺余位', rejected.records?.find(row => row.bookingNo === excess.booking.bookingNo)?.status === 'DRAFT' + && text(rejected.tripRecordVersion) === text(tripVersion) && text(rejectedCapacity.recordVersion) === text(tripVersion) + && stable(rejectedCapacity.availableSegments) === stable(expected)) + const excessCancelled = await request('POST', `/bookings/${encodeURIComponent(excess.booking.bookingNo)}/cancel`, { + tripRecordVersion: tripVersion, recordVersion: excess.booking.recordVersion, idempotencyKey: key(), reason: '结束未承运的超容量测试询价', + }, '正式取消未承运测试询价') + tripVersion = excessCancelled.tripRecordVersion + await check('未承运询价取消成功', excessCancelled.booking?.status === 'CANCELLED') + const cancelBody = { tripRecordVersion: tripVersion, recordVersion: confirmed.booking.recordVersion, + idempotencyKey: key(), reason: '结束本轮未执行外接预订并验证余位释放' } + proof.cancelRequest = cancelBody + const cancelled = await request('POST', `/bookings/${number}/cancel`, cancelBody, '正式取消已承运但未执行预订') + tripVersion = cancelled.tripRecordVersion + await check('已承运预订正式取消', cancelled.booking?.status === 'CANCELLED') + const cancelReplay = await request('POST', `/bookings/${number}/cancel`, cancelBody, '重放取消承运请求') + const released = await request('GET', '/capacity-plan', undefined, '回查取消与重放后的全部余位') + await check('取消仅释放一次并恢复原自有货余位', cancelReplay?.replayed === true + && text(cancelReplay.tripRecordVersion) === text(tripVersion) && text(released.recordVersion) === text(tripVersion) + && stable(released.availableSegments) === stable(proof.initialSegments) && released.planValid === true) + proof.finalSegments = released.availableSegments + proof.finalTripVersion = tripVersion + proof.status = 'VERIFIED' + return proof +} diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs new file mode 100644 index 000000000..7abd55d8c --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs' + +function fixture(fault = '') { + const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 }, + remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } } + const tripProof = { tripNo: 'TR-CURRENT', warehouseId: '92', dispatchNo: 'DP-OWN', + stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], externalReceipt: { + status: 'VERIFIED', createRequest: { customerId: '900719925474099301' }, incomingNo: 'WT-CURRENT', bookingNo: 'FB-CURRENT', + warehousePalletId: '900719925474099311', palletNo: 'PALLET-EXT', finalSegments: [segment], finalBooking: { + cargo: { destinationKey: 'FBA:ONT8' }, receipt: { source: { pallets: [{ weightKg: 100, volumeCbm: 1 }] } }, + }, + } } + let version = 10, linked = false, replanned = false, transferred = false + const calls = [], assertions = [] + const customerId = tripProof.externalReceipt.createRequest.customerId + const item = { warehousePalletId: tripProof.externalReceipt.warehousePalletId, packageCount: 1 } + const booking = () => ({ bookingNo: 'FB-CURRENT', status: linked ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', recordVersion: linked ? 4 : 3, + dispatchNo: linked ? 'DP-EXT' : null }) + const binding = () => ({ tripRecordVersion: version, booking: booking(), replayed: false }) + const dispatches = [{ dispatchNo: 'DP-OWN', review: { status: 'PENDING' } }] + const detail = () => ({ trip: { recordVersion: version, maxPalletSpaces: 30, vehiclePlate: 'TEST-TRUCK', driverName: 'TEST-DRIVER', driverPhone: '19900000000' }, dispatches }) + const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }) + const context = { marker: 'OFFLINE', createdResources: { fleetTrips: [tripProof] }, + assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, + async request(method, path, body) { + calls.push({ method, path, body }) + if (path.endsWith('/capacity-plan')) { + if (method === 'POST') { replanned = true; version++ } + return response({ recordVersion: version, manifestHash: 'a'.repeat(64), planValid: !linked || replanned || fault === 'stale-plan', + capacityPlan: { pallets: [{ warehousePalletId: 'OWN-PALLET', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] }, + cargo: [{ dispatchNo: 'DP-OWN', warehousePalletId: 'OWN-PALLET', packages: 1 }, ...(linked ? [{ dispatchNo: 'DP-EXT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, packages: 1 }] : [])], + availableSegments: replanned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 27 } }] : [segment] }) + } + if (path.includes('/candidates?')) return response([{ customerId: fault === 'foreign-customer' ? 'wrong' : customerId, overseasWarehouseId: '92', + inboundCode: 'WT-CURRENT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, palletNo: 'PALLET-EXT', packageCount: 1, + sourceStage: 'RECEIVED', planPalletId: 'PLAN-PALLET' }]) + if (path.endsWith('/drafts/containers')) return response({ id: 'CONTAINER' }) + if (path.endsWith('/draft-locks/lock')) return response(null) + if (path.endsWith('/pre-arrange')) return response({ dispatchNo: 'DP-EXT', packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0, items: [item] }) + if (path.endsWith('/bookings')) return response({ tripRecordVersion: version, records: [booking()] }) + if (path.endsWith('/bookings/FB-CURRENT/dispatch')) { + if (linked) return response({ ...binding(), replayed: true }) + linked = true; version++; dispatches.push({ dispatchNo: 'DP-EXT', review: { status: 'PENDING' } }); return response(binding()) + } + if (path.endsWith('/review')) { + assert.equal(body.recordVersion, version); version++ + const number = path.split('/').at(-2) + if (fault !== 'review-not-applied') dispatches.find(row => row.dispatchNo === number).review.status = 'APPROVED' + return response(detail()) + } + if (path.endsWith('/trips/TR-CURRENT')) return response(detail()) + if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: false, snapshotHash: 'b'.repeat(64), quotes: [ + { bookingNo: 'FB-CURRENT', incomingNo: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, quantity: 1, unitPrice: 30 }, + ] }) + if (path.endsWith('/transfer-delivery')) { + if (body.fees.length) return response(null, '本排车全部货物已带入外接约定运费,请勿重复填写派送应收') + const replayed = transferred; transferred = true + return response({ dispatchNo: 'DP-EXT', outboundNo: 'OUT-EXT', packageCount: 1, deliveries: [{ deliveryNo: 'DEL-EXT' }], replayed }) + } + assert.match(path, /\/delivery-orders\/DEL-EXT\?customerId=900719925474099301$/) + const rows = [{ feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }] + if (fault === 'duplicate-fee' && calls.filter(row => row.path === path).length > 1) rows.push({ ...rows[0] }) + return response({ order: { dispatchNo: 'DP-EXT' }, fees: rows }) + }, + } + return { context, tripProof, calls, assertions } +} + +test('external dispatch helper preserves reservation when binding physical cargo and publishes only agreed source fee', async () => { + const f = fixture() + const proof = await verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof) + assert.equal(proof.status, 'VERIFIED') + assert.equal(proof.dispatchNo, 'DP-EXT') + assert.equal(proof.sourceFees.length, 1) + assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED') + assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) + assert.ok(f.calls.every(row => !/\/pick|\/depart|\/cancel/.test(row.path))) + await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof), /尚未执行关联/) +}) + +for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee']) { + test(`external dispatch proof blocks ${fault}`, async () => { + const f = fixture(fault) + await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof)) + assert.equal(f.tripProof.externalDispatch.status, 'CHECKING') + assert.ok(f.assertions.some(row => !row.passed)) + }) +} diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs new file mode 100644 index 000000000..76af87ca7 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto' + +const OPTIONS = { scenario: 'fleet-external-dispatch-price', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const key = () => `fleet-dispatch-${randomUUID()}` +const stable = rows => JSON.stringify(rows) + +/** Continues a current-run VERIFIED external receipt. Leaves both dispatches ready for the caller's real PDA picking. */ +export async function verifyFleetExternalDispatch(context, module, tripProof) { + const receipt = tripProof.externalReceipt + if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) + throw new Error('外接关联只接受本轮已验证实收且尚未执行关联的车次') + const proof = tripProof.externalDispatch = { status: 'CHECKING', marker: context.marker, requests: [] } + const customerId = id(receipt.createRequest.customerId) + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}` + const dispatchBase = '/api/platform/v1/last-leg/dispatches' + async function check(name, passed, response) { + context.assert(module, name, response, Boolean(passed), '', OPTIONS) + if (!passed) throw new Error(`${module}: ${name}`) + } + async function request(method, path, body, label, expectedMessage) { + const response = await context.request(method, path, body) + proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + const conflict = response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409') + await check(label, response?.requestId && response?.traceId && (expectedMessage + ? conflict && expectedMessage.test(id(response?.body?.message)) : response?.http === 200 && id(response?.body?.code) === '200'), response) + return response.body?.data + } + const before = await request('GET', `${base}/capacity-plan`, undefined, '关联外接前冻结有效计划与预留') + await check('实际外接仍计入承诺容量', before?.planValid && before.capacityPlan?.pallets?.length > 0 + && stable(before.availableSegments) === stable(receipt.finalSegments)) + const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: receipt.incomingNo })}`, + undefined, '查询本轮真实外接来货的可排车实托') + await check('候选精确匹配本轮客户起运仓来货及实托', Array.isArray(candidates) && candidates.length === 1 + && id(candidates[0].customerId) === customerId && id(candidates[0].overseasWarehouseId) === id(tripProof.warehouseId) + && candidates[0].inboundCode === receipt.incomingNo && id(candidates[0].warehousePalletId) === receipt.warehousePalletId + && candidates[0].palletNo === receipt.palletNo && Number(candidates[0].packageCount) === 1 + && candidates[0].sourceStage === 'RECEIVED' && candidates[0].planPalletId) + const candidate = candidates[0] + proof.draftToken = key() + const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: proof.draftToken, capacity: 30 }, '建立本轮外接排车草稿') + await check('草稿返回明确容器引用', container?.id) + proof.draftContainerId = id(container.id) + await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidate.planPalletId), + draftToken: proof.draftToken, draftContainerId: proof.draftContainerId }, '正式锁定本轮外接实托') + proof.preArrangeRequest = { planPalletIds: [id(candidate.planPalletId)], draftToken: proof.draftToken } + const arranged = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '从真实已收货来源生成外接客户排车') + await check('外接排车只包含本票实收货物', arranged?.dispatchNo && arranged.dispatchNo !== tripProof.dispatchNo + && arranged.items?.length === 1 && id(arranged.items[0].warehousePalletId) === receipt.warehousePalletId + && Number(arranged.packageCount) === 1 && Number(arranged.pickedPackageCount) === 0 && Number(arranged.shippedPackageCount) === 0) + proof.dispatchNo = arranged.dispatchNo + const replay = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '重放外接预排不生成第二张排车') + await check('预排重放保持排车与实托', replay?.dispatchNo === proof.dispatchNo && stable(replay.items) === stable(arranged.items)) + const bookingPath = `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}` + const bookingList = await request('GET', `${base}/bookings`, undefined, '读取关联前最新预订及车次版本') + const booking = bookingList?.records?.find(row => row.bookingNo === receipt.bookingNo) + await check('本票仍为实收完成且未关联', booking?.status === 'RECEIVED' && booking.incomingNo === receipt.incomingNo && !booking.dispatchNo) + proof.bindRequest = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, + dispatchNo: proof.dispatchNo, idempotencyKey: key() } + const linked = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '将本票外接真实排车关联到承诺车次') + await check('关联后预订绑定同一排车', linked?.booking?.status === 'BOUND' && linked.booking.dispatchNo === proof.dispatchNo) + const linkedReplay = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '重放外接排车关联') + await check('关联重放不增加版本或第二次占位', linkedReplay?.replayed === true + && id(linkedReplay.tripRecordVersion) === id(linked.tripRecordVersion) && stable(linkedReplay.booking) === stable(linked.booking)) + const after = await request('GET', `${base}/capacity-plan`, undefined, '读取新增真实货物后的计划容量') + await check('新增外接货物使旧容量确认待重核', after?.planValid === false && after.cargo?.some(row => row.dispatchNo === proof.dispatchNo + && id(row.warehousePalletId) === receipt.warehousePalletId && Number(row.packages) === 1)) + const pallets = new Map(before.capacityPlan.pallets.map(row => [id(row.warehousePalletId), row])) + const actual = receipt.finalBooking.receipt.source.pallets[0] + pallets.set(receipt.warehousePalletId, { warehousePalletId: receipt.warehousePalletId, palletSpaces: 1, + weightKg: Number(actual.weightKg), volumeCbm: Number(actual.volumeCbm), + deliveryIndex: tripProof.stops.findIndex(stop => stop.destinationKey === receipt.finalBooking.cargo.destinationKey) }) + await check('新容量只引用原计划和已核实外接实测', after.cargo.every(row => pallets.has(id(row.warehousePalletId))) + && new Set(after.cargo.map(row => id(row.warehousePalletId))).size === pallets.size) + const replanned = await request('POST', `${base}/capacity-plan`, { recordVersion: after.recordVersion, manifestHash: after.manifestHash, + pallets: [...pallets.values()], reason: '外接预订转实际发运后按本轮实测重新确认,不重复计算原预留' }, '重新核对包含外接实物的全部货物容量') + await check('从预留转实际关联后分段总占用守恒', replanned?.planValid === true && stable(replanned.availableSegments) === stable(before.availableSegments)) + let detail = await request('GET', base, undefined, '读取本车所有客户排车待审核情况') + await check('自有货与外接客户排车均保留', detail?.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo) + && detail.dispatches.some(row => row.dispatchNo === proof.dispatchNo)) + for (const binding of detail.dispatches) detail = await request('POST', `${base}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, + { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }, '关联变化后逐客户通过调度审核') + await check('每张客户执行单都有通过事实', detail.dispatches.every(row => row.review?.status === 'APPROVED')) + const dispatchPath = `${dispatchBase}/${encodeURIComponent(proof.dispatchNo)}` + const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '读取本票实收约定运费') + await check('外接报价精确对应本票实收和确认单价', freight?.requiresDeliveryFee === false && freight.quotes?.length === 1 + && freight.quotes[0].bookingNo === receipt.bookingNo && freight.quotes[0].incomingNo === receipt.incomingNo + && freight.quotes[0].currency === 'CNY' && Number(freight.quotes[0].amount) === 30 + && Number(freight.quotes[0].quantity) === 1 && Number(freight.quotes[0].unitPrice) === 30) + proof.transferRequest = { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), + deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', + vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate, + driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', + remark: `${context.marker} 外接实收报价验收`, externalFreightHash: freight.snapshotHash, fees: [] } + await request('POST', `${dispatchPath}/transfer-delivery`, { ...proof.transferRequest, + idempotencyKey: key(), fees: [{ feeType: 'DELIVERY_FEE', feeName: '重复派送费测试', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, + '全部外接已有约定运费时拒绝重复派送应收', /重复|再次/) + const transferred = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '按实收报价建立本票派送及外接应收来源') + await check('外接只生成本目的地一张派送', transferred?.dispatchNo === proof.dispatchNo && transferred.outboundNo + && transferred.deliveries?.length === 1 && Number(transferred.packageCount) === 1) + proof.outboundNo = transferred.outboundNo; proof.deliveryNo = transferred.deliveries[0].deliveryNo + const deliveryPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(customerId)}` + const delivery = await request('GET', deliveryPath, undefined, '回查本票派送费用来源') + const fees = delivery?.fees?.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE') + await check('本票只登记约定30元应收来源且没有伪零费用', fees?.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY' + && fees[0].sourceStatus === 'ACTIVE' && fees[0].reviewStatus === 'APPROVED' && delivery.order?.dispatchNo === proof.dispatchNo) + const transferReplay = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '外接转派送原请求重放') + const replayedDelivery = await request('GET', deliveryPath, undefined, '重放后回查外接应收未重复') + await check('外接重放保持派送及费用来源', transferReplay?.replayed === true && transferReplay.outboundNo === proof.outboundNo + && stable(replayedDelivery.fees) === stable(delivery.fees)) + proof.customerId = customerId; proof.finalTripVersion = detail.trip.recordVersion + proof.sourceFees = fees; proof.financeAcknowledgement = 'NOT_VERIFIED' + proof.status = 'VERIFIED' + return proof +} diff --git a/scripts/admin-test-env-modules/fleet-external-receipt-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-receipt-acceptance-contract.test.mjs new file mode 100644 index 000000000..41ddf3fc6 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-external-receipt-acceptance-contract.test.mjs @@ -0,0 +1,150 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { verifyFleetExternalReceipt } from './fleet-external-receipt-acceptance.mjs' + +function fixture(fault = '') { + const token = 'offline-secret-pda-token' + const customerId = '900719925474099301' + const tripProof = { tripNo: 'T-RECEIPT', warehouseId: '92', stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } + const baseline = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 }, + remaining: { palletSpaces: 29, weightKg: 17900, volumeCbm: 89 } }] + const finalSegments = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 }, + remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } }] + const reservedSegments = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 3, weightKg: 150, volumeCbm: 1.5 }, + remaining: { palletSpaces: 27, weightKg: 17850, volumeCbm: 88.5 } }] + const calls = [], assertions = [], commands = new Map() + let tripVersion = 10, booking, pallet, taskStatus = 'WAIT_PDA_RECEIVING', stock = 0, ledgers = [] + const ok = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }) + const result = replayed => ok({ tripRecordVersion: tripVersion, booking: { ...booking, + updatedAt: replayed ? '2026-10-02T21:55:52.334Z' : '2026-10-02T21:55:52.334272005Z' }, replayed }) + const task = () => ({ transferNo: 'WT-NEW', inboundCode: 'WT-NEW', operatingWarehouseId: fault === 'foreign-warehouse' ? '93' : '92', + status: taskStatus, pallets: pallet ? [pallet] : [] }) + const evidence = () => ({ status: taskStatus, warehouseId: '92', destinationKey: 'FBA:ONT8', incomingNo: 'WT-NEW', inboundCode: 'WT-NEW', + receiptHash: 'a'.repeat(64), receivedPackages: pallet?.status === 'COMPLETED' ? 1 : 0, + pallets: pallet?.status === 'COMPLETED' ? [{ warehousePalletId: pallet.warehousePalletId, palletNo: pallet.palletNo, packages: 1, weightKg: 100, volumeCbm: 1 }] : [] }) + const ledger = () => ({ id: 'ledger-1', customerId, overseasWarehouseId: '92', inboundCode: 'WT-NEW', + palletNo: 'PLT-NEW', businessOrderNo: 'WT-NEW', beforePackages: 0, deltaPackages: 1, afterPackages: 1 }) + const context = { marker: 'TEST-RECEIPT', createdResources: { fleetTrips: [tripProof] }, + assert(module, name, response, passed, details, options) { assertions.push({ name, passed, options }) }, + async requestMultipart(method, path, file, auth) { + assert.equal(auth, token); assert.equal(file.type, 'image/png'); assert.ok(file.name.includes('TEST-MEASUREMENT')) + calls.push({ method, path }); return ok({ fileId: fault === 'missing-file' ? null : '901', sizeBytes: file.content.length }) + }, + async request(method, path, body, auth) { + calls.push({ method, path, body: structuredClone(body) }) + if (path.includes('/inventory/')) { + const url = new URL(path, 'https://test.invalid') + assert.equal(url.searchParams.get('customerId'), customerId) + const balance = { id: '900719925474099900', customerId, overseasWarehouseId: '92', inboundCode: 'WT-NEW', availablePackages: stock, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 } + if (url.pathname.endsWith('/positions')) return ok([{ id: 'pos-1', balanceId: balance.id, customerId, overseasWarehouseId: '92', + inboundCode: 'WT-NEW', palletId: '900719925474099311', palletNo: 'PLT-NEW', availablePackages: stock }]) + if (url.pathname.endsWith('/balances/900719925474099900')) return ok({ ...balance, inboundCode: fault === 'wrong-balance-source' ? 'WT-OTHER' : balance.inboundCode }) + assert.equal(url.searchParams.get('overseasWarehouseId'), '92') + assert.equal(url.searchParams.get('inboundCode'), 'WT-NEW') + const { inboundCode: omitted, ...group } = balance + const rows = path.includes('/ledger?') ? ledgers : stock ? [group] : [] + return ok({ records: rows, total: rows.length }) + } + if (path.endsWith('/capacity-plan')) return ok({ planValid: true, blockReason: null, recordVersion: tripVersion, + cargo: [{ customerId, destinationKey: 'FBA:ONT8', warehousePalletId: '123' }], + availableSegments: booking?.status === 'RECEIVED' && fault !== 'keeps-reservation' ? finalSegments : booking?.status === 'CONFIRMED' ? reservedSegments : baseline }) + if (path.startsWith('/api/pda/')) { + assert.equal(auth, token) + if (method === 'GET') return ok(task()) + if (commands.has(body.idempotencyKey)) { + if (fault === 'pda-replay-stock' && path.endsWith('/palletize')) { stock++; ledgers.push({ ...ledger(), id: 'ledger-2' }) } + return ok(task()) + } + commands.set(body.idempotencyKey, true) + if (path.endsWith('/pallets/drafts')) pallet = { id: 'pallet-record', status: 'DRAFT', cartonCount: body.cartonCount } + else if (path.endsWith('/generate')) Object.assign(pallet, { status: 'GENERATED', warehousePalletId: '900719925474099311', palletNo: 'PLT-NEW' }) + else if (path.endsWith('/bind')) { + assert.equal(body.palletCode, pallet.palletNo); pallet.status = 'BOUND' + } else if (path.endsWith('/palletize')) { + assert.equal(pallet.status, 'BOUND'); assert.deepEqual(body.photoFileIds, ['901']) + pallet.status = 'COMPLETED'; taskStatus = 'WAIT_CUSTOMER_CONFIRMATION' + if (fault !== 'no-stock') { stock = 1; ledgers = [ledger()] } + } else throw new Error(`unexpected PDA path ${path}`) + return ok(task()) + } + if (method === 'GET' && path.endsWith('/incoming')) return ok({ tripRecordVersion: tripVersion, recordVersion: booking.recordVersion, incoming: evidence() }) + if (method === 'GET' && path.endsWith('/bookings')) { + if (fault === 'persisted-read') throw Error('read interrupted after successful confirmation') + return ok({ tripRecordVersion: tripVersion, records: [{ ...booking, updatedAt: '2026-10-02T21:55:52.334Z' }] }) + } + if (commands.has(body.idempotencyKey)) return result(true) + assert.equal(body.tripRecordVersion, tripVersion) + if (path.endsWith('/bookings')) booking = { bookingNo: 'FB-NEW', customerId, status: 'DRAFT', recordVersion: 0, cargo: body.cargo } + else if (path.endsWith('/confirm')) { booking.status = 'CONFIRMED'; booking.agreedAmount = 50; booking.recordVersion++ } + else if (path.endsWith('/incoming')) { booking.incomingNo = 'WT-NEW'; booking.recordVersion++ } + else if (path.endsWith('/receipt-confirmation')) { + if (taskStatus === 'WAIT_PDA_RECEIVING') return ok(null, '仓库尚未完成实收及测量') + assert.equal(body.receiptHash, 'a'.repeat(64)); assert.equal(body.pallets[0].warehousePalletId, pallet.warehousePalletId) + booking.status = 'RECEIVED'; booking.agreedAmount = fault === 'old-price' ? 50 : 30; booking.recordVersion++ + booking.cargo = { ...booking.cargo, palletSpaces: 1, weightKg: 100, volumeCbm: 1, unitPrice: 30 } + taskStatus = 'READY_FOR_DISPATCH'; booking.receipt = { source: evidence() } + if (fault === 'confirmation-stock') { stock++; ledgers.push({ ...ledger(), id: 'ledger-3' }) } + } else throw new Error(`unexpected platform path ${path}`) + tripVersion++; commands.set(body.idempotencyKey, true) + return result(false) + }, + } + return { context, tripProof, token, calls, assertions, clearFault() { fault = '' } } +} + +test('external receipt proof drives real PDA command chain and checks quantity/price/reservation separately', async () => { + const f = fixture() + const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token) + assert.equal(proof.status, 'VERIFIED') + assert.equal(proof.finalBooking.status, 'RECEIVED') + assert.equal(proof.finalBooking.agreedAmount, 30) + assert.equal(proof.finalInventory.ledger.length, 1) + assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) + assert.ok(!JSON.stringify(proof).includes(f.token)) + assert.ok(f.calls.every(row => !/transfer-delivery|departure|cancel/.test(row.path))) + await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /保留原证据/) +}) + +test('reviewed palletized sample continues with the original PDA key and never recreates or rereceives cargo', async () => { + const f = fixture('wrong-balance-source') + await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /余额与位置确属/) + const original = structuredClone(f.tripProof.externalReceipt.palletizeRequest) + const priorCalls = f.calls.length + assert.equal(f.tripProof.externalReceipt.receiptRequest, undefined) + f.clearFault() + const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcilePalletized: true }) + const writes = f.calls.slice(priorCalls).filter(row => row.method !== 'GET') + assert.deepEqual(writes.map(row => row.path.split('/').at(-1)), ['palletize', 'receipt-confirmation', 'receipt-confirmation']) + assert.deepEqual(writes[0].body, original) + assert.equal(proof.status, 'VERIFIED') + assert.equal(proof.finalInventory.balances[0].availablePackages, 1) + assert.equal(proof.finalInventory.positions[0].availablePackages, 1) + assert.equal(proof.finalInventory.ledger.length, 1) + assert.equal(proof.finalBooking.status, 'RECEIVED') + await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcilePalletized: true }), /仅核对/) +}) + +test('confirmed receipt reconciliation replays only the saved confirmation and preserves persisted timestamp and stock', async () => { + const f = fixture('persisted-read') + await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /read interrupted/) + const request = structuredClone(f.tripProof.externalReceipt.receiptRequest), previous = f.calls.length + f.clearFault() + const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcileConfirmed: true }) + const writes = f.calls.slice(previous).filter(row => row.method !== 'GET') + assert.equal(writes.length, 2) + assert.ok(writes.every(row => row.path.endsWith('/receipt-confirmation'))) + for (const row of writes) assert.deepEqual(row.body, request) + assert.equal(proof.finalInventory.ledger.length, 1) + assert.equal(proof.finalInventory.balances[0].availablePackages, 1) + assert.equal(proof.status, 'VERIFIED') +}) + +for (const fault of ['foreign-warehouse', 'missing-file', 'no-stock', 'pda-replay-stock', 'confirmation-stock', 'keeps-reservation', 'old-price']) { + test(`external receipt helper blocks ${fault} instead of treating status as inventory proof`, async () => { + const f = fixture(fault) + await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token)) + assert.equal(f.tripProof.externalReceipt.status, 'CHECKING') + assert.ok(f.assertions.some(row => !row.passed)) + }) +} diff --git a/scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs b/scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs new file mode 100644 index 000000000..bdf509cfd --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs @@ -0,0 +1,196 @@ +import { randomUUID } from 'node:crypto' + +const OPTIONS = { scenario: 'fleet-external-actual-receipt', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const key = () => `fleet-receipt-${randomUUID()}` +const ok = value => value?.http === 200 && id(value?.body?.code) === '200' +const conflict = value => value?.http === 409 || (value?.http === 200 && id(value?.body?.code) === 'A0409') +const snapshot = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) + +/** Dedicated external receipt case; retains RECEIVED goods for subsequent dispatch. Never runs SQL or bypasses PDA. */ +export async function verifyFleetExternalReceipt(context, module, tripProof, pdaToken, { reconcilePalletized = false, reconcileConfirmed = false } = {}) { + const reconciling = reconcilePalletized || reconcileConfirmed + if (!context.createdResources?.fleetTrips?.includes(tripProof) || (tripProof.externalReceipt && !reconciling) || !pdaToken) + throw new Error('外接实收验证须使用本轮车次及已绑定起运仓的PDA账号,失败后保留原证据') + if (reconciling && (tripProof.externalReceipt?.status !== 'CHECKING' || !tripProof.externalReceipt.palletizeRequest?.idempotencyKey + || !tripProof.externalReceipt.warehousePalletId || (reconcileConfirmed ? !tripProof.externalReceipt.receiptRequest?.idempotencyKey : tripProof.externalReceipt.receiptRequest))) throw new Error('仅核对已入库的原来货与原完成命令,确认阶段须与保存的请求一致') + const proof = reconciling ? tripProof.externalReceipt : tripProof.externalReceipt = { status: 'CHECKING', marker: context.marker, requests: [] } + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}` + async function check(name, passed, response) { + context.assert(module, name, response, Boolean(passed), '', OPTIONS) + if (!passed) throw new Error(`${module}: ${name}`) + } + async function record(method, path, response, label, expectedMessage) { + proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + await check(label, response?.requestId && response?.traceId + && (expectedMessage ? conflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response) + return response.body?.data + } + const request = async (method, path, body, label, token, expectedMessage) => + record(method, path, await context.request(method, path, body, token), label, expectedMessage) + let plan = await request('GET', `${base}/capacity-plan`, undefined, '读取本轮真实自有货及可用容量') + await check('外接实收前已有有效自有货计划', plan?.planValid === true && !plan.blockReason && plan.cargo?.length > 0) + const customerId = id(plan.cargo[0].customerId), destinationKey = plan.cargo[0].destinationKey + const destinationIndex = tripProof.stops.findIndex(stop => stop.destinationKey === destinationKey) + if (reconciling) { + const baseline = proof.initialSegments || tripProof.reservation?.finalSegments + const original = reconcileConfirmed ? { ...proof.createRequest?.cargo, palletSpaces: 1, weightKg: 100, volumeCbm: 1 } : proof.createRequest?.cargo + await check('原实收来源及原预留基线可追溯', baseline?.length > 0 && proof.createRequest.customerId === customerId && original?.destinationKey === destinationKey) + const reserved = baseline.map(segment => segment.fromIndex >= destinationIndex ? segment : { ...segment, + occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + Number(original.palletSpaces), weightKg: Number(segment.occupied.weightKg) + Number(original.weightKg), volumeCbm: Number(segment.occupied.volumeCbm) + Number(original.volumeCbm) }, + remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - Number(original.palletSpaces), weightKg: Number(segment.remaining.weightKg) - Number(original.weightKg), volumeCbm: Number(segment.remaining.volumeCbm) - Number(original.volumeCbm) } }) + await check('已收货未确认期间仍保留原承运预留', snapshot(plan.availableSegments) === snapshot(reserved)) + plan = { ...plan, availableSegments: baseline } + } else proof.initialSegments = plan.availableSegments + await check('使用本轮客户和已约定FBA卸货站且容量足够', /^[1-9]\d*$/.test(customerId) + && destinationKey.startsWith('FBA:') && destinationIndex > 0 + && plan.availableSegments?.filter(row => row.fromIndex < destinationIndex).length === destinationIndex + && plan.availableSegments.filter(row => row.fromIndex < destinationIndex).every(row => + Number(row.remaining.palletSpaces) >= 2 && Number(row.remaining.weightKg) >= 100 && Number(row.remaining.volumeCbm) >= 1)) + let bookingPath + if (!reconciling) { + const draft = { tripRecordVersion: plan.recordVersion, customerId, idempotencyKey: key(), + cargo: { destinationKey, cargoName: `${context.marker} 外接实收测试货`, packageCount: 1, palletSpaces: 2, + weightKg: 50, volumeCbm: 0.5, priceBasis: 'PALLET', unitPrice: 25, currency: 'CNY', + remark: '受控测试:计划2托位,PDA实测1托位100kg/1m³,运营重新确认30元/托' } } + proof.createRequest = draft + let result = await request('POST', `${base}/bookings`, draft, '登记本轮专用外接实收预订') + await check('来货由本轮询价产生', result?.booking?.status === 'DRAFT' && result.booking.customerId === customerId && result.booking.bookingNo) + proof.bookingNo = result.booking.bookingNo + bookingPath = `${base}/bookings/${encodeURIComponent(proof.bookingNo)}` + proof.confirmRequest = { tripRecordVersion: result.tripRecordVersion, recordVersion: result.booking.recordVersion, idempotencyKey: key() } + result = await request('POST', `${bookingPath}/confirm`, proof.confirmRequest, '确认来货前承运预留') + await check('预留报价为2托位乘25元', result?.booking?.status === 'CONFIRMED' && Number(result.booking.agreedAmount) === 50) + proof.incomingRequest = { tripRecordVersion: result.tripRecordVersion, recordVersion: result.booking.recordVersion, + estimatedArrivalAt: new Date(Date.now() + 1800000).toISOString(), deliveryInstructions: `${context.marker} 客户自送起运仓`, idempotencyKey: key() } + result = await request('POST', `${bookingPath}/incoming`, proof.incomingRequest, '创建真实外接来货并进入PDA待收货') + proof.incomingNo = result?.booking?.incomingNo + await check('创建来货不提前标记实收', proof.incomingNo && result.booking.status === 'CONFIRMED' && !result.booking.receipt) + const incomingReplay = await request('POST', `${bookingPath}/incoming`, proof.incomingRequest, '原请求重放来货登记') + await check('来货重放复用同一单号和版本', incomingReplay?.replayed === true && incomingReplay.booking?.incomingNo === proof.incomingNo + && id(incomingReplay.tripRecordVersion) === id(result.tripRecordVersion) && id(incomingReplay.booking.recordVersion) === id(result.booking.recordVersion)) + } + bookingPath ||= `${base}/bookings/${encodeURIComponent(proof.bookingNo)}` + const incoming = await request('GET', `${bookingPath}/incoming`, undefined, '读取运营侧来货及实收证据') + await check(reconciling ? '原来货实收阶段与检查点一致' : '实际来货为待PDA收货且没有完成托盘', + incoming?.incoming?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : reconcilePalletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING') + && incoming.incoming.warehouseId === id(tripProof.warehouseId) && incoming.incoming.destinationKey === destinationKey + && incoming.incoming.inboundCode && incoming.incoming.pallets?.length === (reconciling ? 1 : 0)) + proof.inboundCode = incoming.incoming.inboundCode + const filter = new URLSearchParams({ customerId, overseasWarehouseId: id(tripProof.warehouseId), inboundCode: proof.inboundCode, page: '1', size: '200' }) + async function inventory(label) { + const balances = await request('GET', `/api/platform/v1/last-leg/inventory/balances?${filter}`, undefined, `${label}读取本票库存`) + const ledger = await request('GET', `/api/platform/v1/last-leg/inventory/ledger?${filter}&businessOrderNo=${encodeURIComponent(proof.incomingNo)}`, undefined, `${label}读取本票库存流水`) + for (const [kind, page] of [['库存', balances], ['流水', ledger]]) await check(`${label}${kind}精确范围且无漏页`, + Array.isArray(page?.records) && Number(page.total) === page.records.length && page.records.every(row => + id(row.customerId) === customerId && id(row.overseasWarehouseId) === id(tripProof.warehouseId) + && (kind === '库存' || row.inboundCode === proof.inboundCode))) + const exactBalances = [], exactPositions = [] + for (const group of balances.records) { + await check(`${label}分组有可回查的余额引用`, /^[1-9]\d*$/.test(id(group.id))) + const balancePath = `/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(group.id))}` + const balance = await request('GET', `${balancePath}?customerId=${encodeURIComponent(customerId)}`, undefined, `${label}回查实际余额来源`) + const positions = await request('GET', `${balancePath}/positions?customerId=${encodeURIComponent(customerId)}`, undefined, `${label}回查实际余额的实物位置`) + await check(`${label}余额与位置确属本票实际实托`, id(balance?.id) === id(group.id) + && id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === id(tripProof.warehouseId) + && balance.inboundCode === proof.inboundCode && Array.isArray(positions) + && positions.every(row => id(row.balanceId) === id(balance.id) && id(row.customerId) === customerId + && id(row.overseasWarehouseId) === id(tripProof.warehouseId) && row.inboundCode === proof.inboundCode + && id(row.palletId) === proof.warehousePalletId && row.palletNo === proof.palletNo) + && ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages', 'inTransitPackages', 'procurementPackages'] + .every(field => Number(balance[field]) === Number(group[field]))) + exactBalances.push(balance); exactPositions.push(...positions) + } + return { balances: exactBalances, positions: exactPositions, ledger: ledger.records } + } + const before = reconciling ? proof.beforeInventory : await inventory('PDA完成前') + await check('登记来货未伪造库存或入库流水', before.balances.every(row => Number(row.availablePackages) === 0) + && before.ledger.length === 0) + proof.beforeInventory = before + const pdaBase = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}` + const task = await request('GET', pdaBase, undefined, 'PDA按绑定仓读取本轮来货', pdaToken) + await check('PDA真实任务与来货、客户来源及仓库一致', task?.transferNo === proof.incomingNo && task.inboundCode === proof.inboundCode + && id(task.operatingWarehouseId) === id(tripProof.warehouseId) && task.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : reconcilePalletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING') + && task.pallets?.length === (reconciling ? 1 : 0)) + let completed + if (reconciling) completed = task + else { + const early = { tripRecordVersion: incoming.tripRecordVersion, recordVersion: incoming.recordVersion, + receiptHash: incoming.incoming.receiptHash, pallets: [{ warehousePalletId: id(plan.cargo[0].warehousePalletId), palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key() } + await request('POST', `${bookingPath}/receipt-confirmation`, early, '未完成PDA测量禁止运营提前确认实收', undefined, /仓库尚未完成实收及测量/) + proof.palletDraftRequest = { idempotencyKey: key(), cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 } + const measured = await request('POST', `${pdaBase}/pallets/drafts`, proof.palletDraftRequest, 'PDA登记本轮受控实物测量', pdaToken) + await check('测量仅产生待生成实托草稿', measured?.pallets?.length === 1 && measured.pallets[0].status === 'DRAFT' && measured.pallets[0].id) + const draftReplay = await request('POST', `${pdaBase}/pallets/drafts`, proof.palletDraftRequest, '测量草稿原键重放', pdaToken) + await check('测量重放不增加托盘', snapshot(draftReplay.pallets) === snapshot(measured.pallets)) + const palletPath = `${pdaBase}/pallets/${encodeURIComponent(id(measured.pallets[0].id))}` + const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key() }, '生成仓库权威实物托盘', pdaToken) + const pallet = generated?.pallets?.[0] + await check('生成真实托盘且未入库', generated.pallets.length === 1 && pallet?.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo) + proof.warehousePalletId = id(pallet.warehousePalletId); proof.palletNo = pallet.palletNo + const bound = await request('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key() }, '按真实托盘码完成PDA绑定', pdaToken) + await check('PDA绑定保留同一实托和实测数量', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND' + && id(bound.pallets[0].warehousePalletId) === proof.warehousePalletId && Number(bound.pallets[0].cartonCount) === 1) + const photoPath = `${pdaBase}/measurement-photos/files` + const uploaded = await record('POST', photoPath, await context.requestMultipart('POST', photoPath, { + name: `${context.marker}-TEST-MEASUREMENT.png`, type: 'image/png', + content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'), + }, pdaToken), '上传明确标记为测试的测量凭证') + await check('测量凭证有真实文件引用', uploaded?.fileId && Number(uploaded.sizeBytes) > 0) + proof.photoFileId = id(uploaded.fileId) + proof.palletizeRequest = { idempotencyKey: key(), photoFileIds: [proof.photoFileId] } + completed = await request('POST', `${pdaBase}/palletize`, proof.palletizeRequest, 'PDA正式完成外接收货及库存入账', pdaToken) + } + await check('PDA已完成且保留原实托', completed?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : 'WAIT_CUSTOMER_CONFIRMATION') + && completed.pallets?.length === 1 && completed.pallets[0].status === 'COMPLETED' + && id(completed.pallets[0].warehousePalletId) === proof.warehousePalletId) + const receivedStock = await inventory('首次完成收货后') + await check('本票真实可用库存及不可变入库流水只增加1箱', receivedStock.balances.reduce((n, row) => n + Number(row.availablePackages), 0) === 1 + && receivedStock.positions.length === 1 && Number(receivedStock.positions[0].availablePackages) === 1 + && receivedStock.ledger.length > 0 && receivedStock.ledger.every(row => row.businessOrderNo === proof.incomingNo && row.palletNo === proof.palletNo + && Number(row.beforePackages) === 0 && Number(row.deltaPackages) === 1 && Number(row.afterPackages) === 1) + && receivedStock.ledger.reduce((n, row) => n + Number(row.deltaPackages), 0) === 1) + proof.receivedInventory = receivedStock + if (!reconcileConfirmed) { + const completedReplay = await request('POST', `${pdaBase}/palletize`, proof.palletizeRequest, 'PDA完成原请求重放', pdaToken) + const replayedStock = await inventory('PDA完成重放后') + await check('PDA重放不重复写入库存与流水', snapshot(replayedStock.balances) === snapshot(receivedStock.balances) + && snapshot(replayedStock.positions) === snapshot(receivedStock.positions) + && snapshot(replayedStock.ledger) === snapshot(receivedStock.ledger) && snapshot(completedReplay.pallets) === snapshot(completed.pallets)) + } + const measuredIncoming = await request('GET', `${bookingPath}/incoming`, undefined, '运营读取PDA完成后的实测摘要') + await check('运营读取真实实收数量而非原预估', measuredIncoming?.incoming?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : 'WAIT_CUSTOMER_CONFIRMATION') + && measuredIncoming.incoming.pallets?.length === 1 && id(measuredIncoming.incoming.pallets[0].warehousePalletId) === proof.warehousePalletId + && Number(measuredIncoming.incoming.receivedPackages) === 1) + if (!reconcileConfirmed) proof.receiptRequest = { tripRecordVersion: measuredIncoming.tripRecordVersion, recordVersion: measuredIncoming.recordVersion, + receiptHash: measuredIncoming.incoming.receiptHash, pallets: [{ warehousePalletId: proof.warehousePalletId, palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key() } + const accepted = await request('POST', `${bookingPath}/receipt-confirmation`, proof.receiptRequest, '运营按实际1托位和新单价30确认实收') + proof.acceptedReceipt = accepted + await check('实收确认冻结新货量和价格且形成可排车来源', accepted?.booking?.status === 'RECEIVED' + && accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH' && Number(accepted.booking.agreedAmount) === 30 + && Number(accepted.booking.cargo?.palletSpaces) === 1 && Number(accepted.booking.cargo?.weightKg) === 100 + && Number(accepted.booking.cargo?.volumeCbm) === 1 && Number(accepted.booking.cargo?.unitPrice) === 30) + const persisted = await request('GET', `${base}/bookings`, undefined, '冻结已持久化实收记录以核对重放') + const persistedBooking = persisted?.records?.find(row => row.bookingNo === proof.bookingNo) + const { updatedAt: responseTime, ...acceptedFacts } = accepted.booking + const { updatedAt: storedTime, ...persistedFacts } = persistedBooking || {} + await check('持久化保留全部实收业务字段与版本', snapshot([acceptedFacts]) === snapshot([persistedFacts]) + && id(persisted.tripRecordVersion) === id(accepted.tripRecordVersion)) + const acceptedReplay = await request('POST', `${bookingPath}/receipt-confirmation`, proof.receiptRequest, '运营实收确认原键重放') + await check('实收重放不改价格或版本', acceptedReplay?.replayed === true && snapshot([acceptedReplay.booking]) === snapshot([persistedBooking]) + && id(acceptedReplay.tripRecordVersion) === id(accepted.tripRecordVersion)) + const finalStock = await inventory('运营确认及重放后') + await check('运营确认不会再次入库', snapshot(finalStock.balances) === snapshot(receivedStock.balances) + && snapshot(finalStock.positions) === snapshot(receivedStock.positions) + && snapshot(finalStock.ledger) === snapshot(receivedStock.ledger)) + const finalPlan = await request('GET', `${base}/capacity-plan`, undefined, '实收确认后回查分段容量') + const expected = plan.availableSegments.map(segment => segment.fromIndex >= destinationIndex ? segment : { ...segment, + occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + 1, weightKg: Number(segment.occupied.weightKg) + 100, volumeCbm: Number(segment.occupied.volumeCbm) + 1 }, + remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - 1, weightKg: Number(segment.remaining.weightKg) - 100, volumeCbm: Number(segment.remaining.volumeCbm) - 1 } }) + await check('实收替换原预留而非重复占用且仅影响约定站段', finalPlan?.planValid === true && snapshot(finalPlan.availableSegments) === snapshot(expected)) + proof.finalBooking = accepted.booking; proof.finalTripVersion = accepted.tripRecordVersion + proof.finalInventory = finalStock; proof.finalSegments = finalPlan.availableSegments + proof.status = 'VERIFIED' + return proof +} diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs new file mode 100644 index 000000000..53d296077 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' + +function fixture(mode = 'FULL', fault = '') { + const source = { marker: 'CURRENT-TEST', mode, dispatchNo: 'D-CURRENT', customerId: '900719925474099301', + transferBody: { loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [ + { direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }, + ] } } + const cargo = [1, 2].map(n => ({ dispatchItemId: `item-${n}`, warehousePalletId: `pallet-${n}`, orderId: `order-${n}`, + sourceRegion: 'FIRSTLEG', sourceInboundId: `90071992547409940${n}`, inboundCode: `IN-${n}`, packages: 2 })) + const fees = cargo.map((row, index) => ({ financeFeeId: `90071992547409950${index}`, sourceVersion: 1, sourceHash: `fee-hash-${index}`, + sourceInboundId: row.sourceInboundId, customerId: source.customerId, sourceType: 'INBOUND_FEE', currency: 'CNY', amount: 200, status: 'AVAILABLE' })) + if (fault === 'foreign-customer') fees[0].customerId = 'different-customer' + if (fault === 'missing-source') fees.splice(1) + const hash = 'a'.repeat(64), calls = [], assertions = [] + let savedBody, replayed = false, dirtyOutbound = false + const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data }, requestId: 'req-test', traceId: 'trace-test' }) + const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: [source] }, + assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, + async request(method, path, body) { + calls.push({ method, path, body }) + if (method === 'GET' && path === '/api/platform/v1/last-leg/dispatches/D-CURRENT') + return response({ dispatchNo: source.dispatchNo, pickedPackageCount: 0, shippedPackageCount: 0, + items: cargo.map((row, index) => ({ id: row.dispatchItemId, packageCount: row.packages, + destinationWarehouseCode: `FBA-${index + 1}`, fbaWarehouseCode: `ORIGINAL-FBA-${index + 1}` })) }) + if (method === 'GET' && path.endsWith('/fee-coverage')) return response({ dispatchNo: source.dispatchNo, sourceComplete: true, + cargoHash: hash, cargo, feeSources: fees.map(row => fault === 'mutated-original' && savedBody ? { ...row, amount: 201 } : row) }) + if (method === 'GET' && path.includes('/outbound-orders?')) { + const params = new URL(path, 'https://test.invalid').searchParams + assert.equal(params.get('customerId'), source.customerId) + assert.equal(params.has('dispatchNo'), false, 'outbound list does not support dispatchNo filtering') + const rows = dirtyOutbound ? [{ outboundNo: 'OUT-WRONG', dispatchNo: source.dispatchNo }] : [{ outboundNo: 'OUT-OTHER', dispatchNo: 'DS-OTHER' }] + return response({ records: rows, total: rows.length, page: 1, size: 200 }) + } + if (method === 'GET' && path === '/api/platform/v1/last-leg/outbound-orders/OUT-CURRENT') return response({ + outbound: { outboundNo: 'OUT-CURRENT', dispatchNo: source.dispatchNo, status: 'WAIT_OUTBOUND' }, + deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY' })), + }) + if (method === 'GET' && path.endsWith('/external-freight')) return response({ snapshotHash: 'b'.repeat(64), quotes: [], requiresDeliveryFee: true }) + if (method === 'POST' && path.endsWith('/transfer-delivery')) { + if (body.feeCoverageCargoHash !== hash) { + if (fault === 'failed-command-writes') dirtyOutbound = true + return response(null, '原费用对应货物已变化,请重新核对覆盖范围') + } + if (body.feeCoverage[0].sourceVersion !== 1) return response(null, '原费用版本已变化,请重新确认服务覆盖范围') + if (mode === 'FULL' && body.fees.length) return response(null, '本次货物的该服务已由原费用覆盖,请勿重复登记应收') + if (mode === 'PARTIAL' && !body.fees.length) return response(null, '尚未有约定运费的货物须填写派送费') + replayed = Boolean(savedBody) + if (savedBody) assert.deepEqual(body, savedBody) + savedBody = structuredClone(body) + return response({ dispatchNo: source.dispatchNo, outboundNo: 'OUT-CURRENT', outboundStatus: 'WAIT_OUTBOUND', replayed, + deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, deliveryStatus: 'WAIT_DELIVERY' })) }) + } + const detail = path.match(/^\/api\/platform\/v1\/last-leg\/delivery-orders\/DEL-([12])\?customerId=900719925474099301$/) + assert.ok(detail, `Unexpected ${method} ${path}`) + const index = Number(detail[1]) - 1 + const selected = savedBody.feeCoverage.filter(selection => selection.financeFeeId === fees[index].financeFeeId) + const references = selected.map(selection => ({ source: fees[index], services: selection.services })) + const shouldCharge = mode === 'PARTIAL' && (fault === 'charge-covered-destination' ? index === 0 : index === 1) + const newFee = { feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, sourceStatus: 'ACTIVE' } + return response({ order: { dispatchNo: source.dispatchNo, deliveryNo: `DEL-${index + 1}`, destinationWarehouseCode: `FBA-${index + 1}` }, + fees: shouldCharge || (fault === 'duplicate-new-fee' && index === 0) || (fault === 'replay-adds-fee' && replayed) ? [newFee] : [], + feeCoverage: { cargoHash: hash, operatorId: 'op-test', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-03T02:00:00Z' : '2026-10-03T01:00:00Z', + cargo: fault === 'wrong-cargo' ? [cargo[0]] : [cargo[index]], references: fault === 'missing-reference' ? [] : references } }) + }, + } + return { source, context, calls, assertions, clearFault() { fault = '' } } +} + +test('persisted reconciliation reads exact original documents and only replays the original successful command', async () => { + const f = fixture('FULL', 'wrong-cargo') + await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source)) + assert.equal(f.source.proof.outboundNo, 'OUT-CURRENT') + const originalKey = f.source.proof.confirmationRequest.idempotencyKey + const before = f.calls.length + f.clearFault() + const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true }) + assert.equal(proof.status, 'VERIFIED') + const commands = f.calls.slice(before).filter(row => row.method === 'POST') + assert.ok(commands.length > 0) + assert.ok(commands.every(row => row.body.idempotencyKey === originalKey)) + assert.ok(f.calls.slice(before).some(row => row.method === 'GET' && row.path.endsWith('/outbound-orders/OUT-CURRENT'))) +}) + +for (const mode of ['FULL', 'PARTIAL']) { + test(`${mode} fee coverage helper checks source scope, pre-write conflicts, each destination and replay`, async () => { + const f = fixture(mode) + const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) + assert.equal(proof.status, 'VERIFIED') + assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5) + assert.deepEqual(proof.deliveryNos, ['DEL-1', 'DEL-2']) + assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) + assert.ok(f.calls.every(call => !/cancel|receipt|departure/.test(call.path))) + await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) + }) +} + +for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', + 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee']) { + test(`fee coverage HTTP proof cannot pass ${fault}`, async () => { + const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) + await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source)) + assert.equal(f.source.proof.status, 'CHECKING') + assert.ok(f.assertions.some(row => !row.passed)) + if (['foreign-customer', 'missing-source'].includes(fault)) assert.equal(f.calls.some(row => row.method === 'POST'), false) + }) +} diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs new file mode 100644 index 000000000..eec10d48c --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs @@ -0,0 +1,167 @@ +import { randomUUID } from 'node:crypto' + +const OPTIONS = { scenario: 'fleet-original-fee-coverage', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const ok = response => response?.http === 200 && id(response?.body?.code) === '200' +const businessConflict = response => response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409') +const canonical = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) +const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), + orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) +const feeIdentity = row => ({ financeFeeId: id(row.financeFeeId), sourceVersion: Number(row.sourceVersion), sourceHash: row.sourceHash, + sourceInboundId: id(row.sourceInboundId), customerId: id(row.customerId), currency: row.currency, amount: Number(row.amount), status: row.status }) + +/** Dedicated current-run dispatches only: one FULL case and a separate PARTIAL case with >=2 actual inbound sources. */ +export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false } = {}) { + if (!context.createdResources?.fleetFeeCoverageCases?.includes(source) || source.marker !== context.marker || (source.proof && !reconcilePersisted) + || !['FULL', 'PARTIAL'].includes(source.mode) || !source.dispatchNo || !source.customerId) + throw new Error('原费用验收需要本轮专用、未执行的排车用例;失败后保留原请求,不重复创建') + if (reconcilePersisted && (source.mode !== 'FULL' || source.proof?.status !== 'CHECKING' + || !source.proof.outboundNo || !source.proof.deliveryNos?.length || !source.proof.confirmationRequest?.idempotencyKey)) + throw new Error('已落单核对须有原成功请求、出库单及派送集合,不允许重造') + const proof = reconcilePersisted ? source.proof : source.proof = { status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } + const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(source.dispatchNo)}` + const outboundPath = `/api/platform/v1/last-leg/outbound-orders?size=200&customerId=${encodeURIComponent(source.customerId)}` + async function check(name, passed, response) { + context.assert(module, name, response, Boolean(passed), '', OPTIONS) + if (!passed) throw new Error(`${module}: ${name}`) + } + async function request(method, path, body, label, expectedMessage) { + const response = await context.request(method, path, body) + proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + await check(label, Boolean(response?.requestId) && Boolean(response?.traceId) + && (expectedMessage ? businessConflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response) + return response.body?.data + } + async function noOutbound(label) { + const rows = [] + let total + for (let index = 1; ; index++) { + const page = await request('GET', `${outboundPath}&page=${index}`, undefined, label) + await check('客户出库列表分页元数据完整', Array.isArray(page?.records) && Number.isSafeInteger(Number(page.total)) + && Number(page.total) >= 0 && Number(page.page) === index && Number(page.size) === 200) + total ??= Number(page.total) + await check('客户出库列表跨页范围稳定', total === Number(page.total) && (page.records.length > 0 || rows.length === total)) + rows.push(...page.records) + if (rows.length >= total) break + } + await check('失败命令没有生成本排车出库事实', rows.length === total + && new Set(rows.map(row => row.outboundNo)).size === rows.length && rows.every(row => row.dispatchNo !== source.dispatchNo)) + } + const dispatch = await request('GET', dispatchPath, undefined, '核对本轮费用验收排车来源') + await check('只处理本轮尚未拣货及发车的排车', dispatch?.dispatchNo === source.dispatchNo + && Number(dispatch.pickedPackageCount) === 0 + && Number(dispatch.shippedPackageCount) === 0 && dispatch.items?.length > 0) + if (!reconcilePersisted) await noOutbound('确认专用排车尚无出库单') + const preview = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '读取本次实际货物及原 Finance 费用来源') + await check('原费用范围完整且可确认', preview?.sourceComplete === true && !preview.blockReason + && preview.dispatchNo === source.dispatchNo && /^[0-9a-f]{64}$/.test(preview.cargoHash || '') + && preview.cargo?.length > 0 && preview.cargo.every(row => row.sourceRegion === 'FIRSTLEG' && id(row.sourceInboundId))) + const itemTotals = new Map() + for (const row of preview.cargo) itemTotals.set(id(row.dispatchItemId), (itemTotals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) + await check('费用预览逐条排车箱数核平', itemTotals.size === dispatch.items.length + && dispatch.items.every(row => itemTotals.get(id(row.id)) === Number(row.packageCount))) + const inboundIds = [...new Set(preview.cargo.map(row => id(row.sourceInboundId)))].sort() + await check('原费用均属于当前客户和实际原入仓', Array.isArray(preview.feeSources) && preview.feeSources.every(fee => + id(fee.customerId) === id(source.customerId) && inboundIds.includes(id(fee.sourceInboundId)) + && ['AVAILABLE', 'SETTLED'].includes(fee.status) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(fee.sourceType) + && fee.currency === 'CNY' && Number(fee.sourceVersion) >= 1 && fee.sourceHash && id(fee.financeFeeId))) + const sources = inboundIds.map(inboundId => preview.feeSources.find(fee => id(fee.sourceInboundId) === inboundId)) + await check('本轮每票已产生可引用原费用,不借其他票费用补齐', sources.every(Boolean) + && (source.mode === 'FULL' || inboundIds.length >= 2)) + const selectedFees = source.mode === 'FULL' ? sources : [sources[0]] + const selections = selectedFees.map(fee => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: Number(fee.sourceVersion), + sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] })) + const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '核对本专用排车没有外接报价混入覆盖用例') + await check('本用例为原费用货物且使用托盘派送自有预约', Array.isArray(freight?.quotes) && freight.quotes.length === 0 + && freight.requiresDeliveryFee === true && source.transferBody?.loadingType === 'PALLET' + && source.transferBody.appointmentAccount === 'SELF' && source.transferBody.fees?.length === 1 + && source.transferBody.fees[0].direction === 'RECEIVABLE' && source.transferBody.fees[0].feeType === 'DELIVERY_FEE' + && Number(source.transferBody.fees[0].amount) > 0) + const body = reconcilePersisted ? proof.confirmationRequest : { ...source.transferBody, idempotencyKey: `fleet-fee-${randomUUID()}`, + externalFreightHash: freight.snapshotHash, feeCoverageCargoHash: preview.cargoHash, feeCoverage: selections, + fees: source.mode === 'FULL' ? [] : source.transferBody.fees } + proof.confirmationRequest = body + if (!reconcilePersisted) proof.originalFees = preview.feeSources.map(feeIdentity) + const transferPath = `${dispatchPath}/transfer-delivery` + let transferred + if (reconcilePersisted) { + await check('原成功请求的货物与引用仍对应本次核对范围', body.feeCoverageCargoHash === preview.cargoHash + && canonical(body.feeCoverage) === canonical(selections)) + const stored = await request('GET', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, undefined, '先核对已持久化的原出库与派送集合') + await check('原出库和派送存在且尚未执行', stored?.outbound?.dispatchNo === source.dispatchNo + && stored.outbound.outboundNo === proof.outboundNo && stored.outbound.status === 'WAIT_OUTBOUND' + && canonical((stored.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos) + && stored.deliveries.every(row => row.status === 'WAIT_DELIVERY')) + transferred = await request('POST', transferPath, body, '确认原持久化单据后仅重放同键同载荷成功命令') + await check('核对重放未新建任何业务单据', transferred?.replayed === true && transferred.outboundNo === proof.outboundNo + && canonical((transferred.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)) + } else { + const staleHash = `${preview.cargoHash[0] === '0' ? '1' : '0'}${preview.cargoHash.slice(1)}` + await request('POST', transferPath, { ...body, idempotencyKey: `stale-cargo-${randomUUID()}`, feeCoverageCargoHash: staleHash }, + '货物摘要陈旧时拒绝转派送', /货物已变化/) + await noOutbound('陈旧货物拒绝后回查无业务写入') + await request('POST', transferPath, { ...body, idempotencyKey: `stale-fee-${randomUUID()}`, + feeCoverage: selections.map((row, index) => index === 0 ? { ...row, sourceVersion: row.sourceVersion + 1 } : row) }, + '原费用版本陈旧时拒绝确认', /原费用版本已变化/) + await noOutbound('陈旧原费用拒绝后回查无业务写入') + await request('POST', transferPath, { ...body, idempotencyKey: `fee-scope-${randomUUID()}`, + fees: source.mode === 'FULL' ? source.transferBody.fees : [] }, + source.mode === 'FULL' ? '全覆盖禁止再登记派送应收' : '部分覆盖仍须登记未覆盖派送费', + source.mode === 'FULL' ? /已由原费用覆盖/ : /须填写派送费/) + await noOutbound('覆盖金额校验拒绝后回查无业务写入') + transferred = await request('POST', transferPath, body, '正式保存本次原费用服务覆盖并转派送') + } + await check('转派送生成本次真实出库和各目的地派送', transferred?.dispatchNo === source.dispatchNo && transferred.outboundNo + && transferred.outboundStatus === 'WAIT_OUTBOUND' && transferred.deliveries?.length > 0 + && transferred.deliveries.every(row => row.deliveryNo && row.deliveryStatus === 'WAIT_DELIVERY')) + proof.outboundNo = transferred.outboundNo + proof.deliveryNos = transferred.deliveries.map(row => row.deliveryNo).sort() + const evidenceByDelivery = {} + const feesByDelivery = {} + let newReceivable = 0 + const allCargo = [] + for (const number of proof.deliveryNos) { + const detailPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}` + const detail = await request('GET', detailPath, undefined, '回查派送详情中的原费用确认快照') + const evidence = detail?.feeCoverage + const destination = id(detail?.order?.destinationWarehouseCode || detail?.order?.destinationWarehouseId) + const destinationItems = new Set(dispatch.items.filter(row => id(row.destinationWarehouseCode || row.fbaWarehouseCode) === destination).map(row => id(row.id))) + await check('费用确认货物只属于当前派送目的地', destination && destinationItems.size > 0 + && evidence?.cargo?.every(row => destinationItems.has(id(row.dispatchItemId)))) + await check('确认快照属于本次派送及实际原费用版本', detail?.order?.dispatchNo === source.dispatchNo + && detail.order.deliveryNo === number && evidence?.cargoHash === preview.cargoHash + && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) && evidence.cargo?.length > 0 + && Array.isArray(evidence.references) && evidence.references.every(ref => evidence.cargo.some(row => id(row.sourceInboundId) === id(ref.source?.sourceInboundId)) && selectedFees.some(fee => + id(fee.financeFeeId) === id(ref.source?.financeFeeId) && Number(fee.sourceVersion) === Number(ref.source?.sourceVersion) + && fee.sourceHash === ref.source?.sourceHash && canonical(ref.services || []) === canonical(['DELIVERY_FEE'])))) + const selectedInbounds = new Set(selectedFees.map(fee => id(fee.sourceInboundId))) + await check('快照保留本派送已选择的每票服务引用', evidence.cargo.every(row => !selectedInbounds.has(id(row.sourceInboundId)) + || evidence.references.some(ref => id(ref.source?.sourceInboundId) === id(row.sourceInboundId) && ref.services.includes('DELIVERY_FEE')))) + await check('费用明细为明确应收列表', Array.isArray(detail.fees) && detail.fees.every(row => row.feeDirection && row.feeType)) + const added = detail.fees.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE' && row.sourceStatus !== 'VOIDED') + await check('已全覆盖目的地不承担其他目的地的新增派送费', !evidence.cargo.every(row => selectedInbounds.has(id(row.sourceInboundId))) || added.length === 0) + await check('新增派送费没有伪零值或混合币种', added.every(row => Number(row.amount) > 0 && row.currency === source.transferBody.fees[0].currency)) + newReceivable += added.reduce((sum, row) => sum + Number(row.amount), 0) + allCargo.push(...evidence.cargo.map(cargoIdentity)) + evidenceByDelivery[number] = evidence + feesByDelivery[number] = canonical(detail.fees) + } + await check('多目的地快照汇总与实际原货物一致且不重复', canonical(allCargo) === canonical(preview.cargo.map(cargoIdentity))) + await check('仅未覆盖部分产生本次新增应收', Math.round(newReceivable * 100) + === (source.mode === 'FULL' ? 0 : Math.round(Number(source.transferBody.fees[0].amount) * 100))) + const replay = await request('POST', transferPath, body, '原费用确认成功后同键重放') + await check('重放保持同一出库和派送集合', replay?.replayed === true && replay.outboundNo === proof.outboundNo + && canonical((replay.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)) + for (const number of proof.deliveryNos) { + const detail = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`, undefined, '重放后回查覆盖证据') + await check('重放未改写确认人时间和原费用快照', JSON.stringify(detail.feeCoverage) === JSON.stringify(evidenceByDelivery[number])) + await check('成功命令重放未追加费用', Array.isArray(detail.fees) && canonical(detail.fees) === feesByDelivery[number]) + } + const after = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '回查引用原费用未改变其来源事实') + await check('原费用版本金额与状态保持不变', canonical(after.feeSources.map(feeIdentity)) === canonical(proof.originalFees)) + proof.snapshots = evidenceByDelivery + proof.newReceivable = newReceivable + proof.status = 'VERIFIED' + return proof +} diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs new file mode 100644 index 000000000..667c33827 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -0,0 +1,273 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs' + +const stops = [{ name: 'Origin' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] +const cargo = (customerId = 'A') => ({ dispatchNo: `DP-${customerId}`, customerId, warehousePalletId: '9007199254740993', palletNo: 'PLT-1', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }) + +test('test loading measures physical pallet once across customers and rejects conflicting unloading points', () => { + assert.deepEqual(controlledPalletMeasurements([cargo(), cargo('B')], stops), [ + { warehousePalletId: '9007199254740993', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }, + ]) + assert.throws(() => controlledPalletMeasurements([{ ...cargo(), warehousePalletId: null }], stops), /缺少真实托盘/) + assert.throws(() => controlledPalletMeasurements([cargo()], [{ name: 'Origin' }]), /不在车次路线/) + assert.throws(() => controlledPalletMeasurements([cargo(), { ...cargo('B'), destinationKey: 'FBA:LAX9' }], [...stops, { destinationKey: 'FBA:LAX9' }]), /须先拆托/) +}) + +test('fixture prepares actual qualification upload and review before assignment and copies authoritative destination keys', async () => { + const calls = [] + let version = 0 + const context = { + marker: 'TEST-FLEET', createdResources: {}, + apiSuccess(_module, _label, response) { return response.body.data }, + async request(method, path, body) { + calls.push({ method, path, body }) + let data + if (path.endsWith('/master-data/vehicles')) { + assert.equal(body.status, 'IN_SERVICE', 'VehicleCreate requires an explicit status and trip assignment requires an in-service vehicle') + data = { id: 'vehicle-id' } + } + else if (path.endsWith('/drivers')) data = { id: 'driver-id', recordVersion: 0 } + else if (path.includes('/dispatches/') && path.endsWith('/review')) data = { trip: { recordVersion: version++ }, dispatches: [{ dispatchNo: 'DP-A', review: { status: 'APPROVED' } }] } + else if (path.endsWith('/review')) data = { reviewStatus: 'APPROVED' } + else if (path.endsWith('/loading')) data = { cargo: [cargo()] } + else { data = { trip: { tripNo: 'TR-FIXTURE', recordVersion: version++, stops: body.stops || stops } } } + return { http: 200, body: { code: '200', data } } + }, + async requestMultipart(method, path, file) { + calls.push({ method, path, file }) + assert.equal(file.type, 'image/png'); assert.ok(file.content.byteLength > 0) + return { http: 200, body: { code: '200', data: { id: 'driver-id', recordVersion: 1, licenseFileId: 'file-id' } } } + }, + } + const proof = await prepareFleetTrip(context, 'test', 'DP-A', '92') + assert.equal(proof.tripNo, 'TR-FIXTURE') + assert.equal(proof.reassignment.status, 'PENDING') + assert.ok(calls.findIndex(call => call.path.includes('/license?')) < calls.findIndex(call => call.path.endsWith('/review'))) + assert.ok(calls.findIndex(call => call.path.endsWith('/review')) < calls.findIndex(call => call.path.endsWith('/assignment'))) + assert.equal(calls.find(call => call.path.endsWith('/plan')).body.stops[1].destinationKey, 'FBA:ONT8') + assert.equal(context.createdResources.fleetTrips[0].licenseFileId, 'file-id') + assert.ok(calls.every(call => !call.path.endsWith('/depart'))) +}) + +test('unready actual dispatch prevents load confirmation rather than bypassing it with test values', async () => { + const calls = [] + const context = { request: async (method, path) => { calls.push({ method, path }); return { http: 200, body: { code: '200', data: { blockReason: '拣货未完成' } } } }, + apiSuccess(_module, _name, response) { return response.body.data } } + await assert.rejects(confirmFleetLoading(context, 'test', { tripNo: 'TR-1', stops }), /整车未就绪/) + assert.equal(calls.length, 1) + assert.equal(calls[0].method, 'GET') +}) + +function reassignmentServer(fault = '') { + const proof = { tripNo: 'TR-FRESH', dispatchNo: 'DP-A', vehicleId: '101', driverId: '201', stops, + reassignment: { status: 'PENDING' } } + const trip = { tripNo: proof.tripNo, recordVersion: 4, vehicleId: '101', driverId: '201', vehiclePlate: 'ORIGINAL', + driverName: 'Original driver', driverPhone: '123', status: 'PLANNED', stops, maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } + const dispatch = { dispatchNo: proof.dispatchNo, overseasWarehouseId: '92', status: 'READY_TO_DEPART', + packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, remainingPackageCount: 0, + estimatedArrivalAt: '2026-10-03T12:00:00', items: [{ id: '9007199254740995', inboundCode: 'IN-TEST', packageCount: 10 }], + vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone } + const calls = [], assertions = [], dispatches = [{ dispatchNo: proof.dispatchNo, customerId: 'A', review: { status: 'APPROVED' } }] + let load = null, currentCargo = [cargo()], assigned = false + const ok = data => ({ http: 200, body: { code: '200', data: fault === 'string-versions' + ? JSON.parse(JSON.stringify(data, (key, value) => key === 'recordVersion' ? String(value) : value)) : structuredClone(data) } }) + if (fault === 'string-versions') trip.recordVersion = 8 + const loading = () => ({ recordVersion: trip.recordVersion, manifestHash: 'a'.repeat(64), cargo: currentCargo, confirmedLoad: load, blockReason: null }) + const context = { + marker: 'TEST-REASSIGN', + assert(_module, label, _response, passed) { assertions.push({ label, passed }) }, + apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null }, + async requestMultipart(method, path, file) { + calls.push({ method, path }); assert.equal(file.type, 'image/png') + return ok({ licenseFileId: 'FILE-NEW', recordVersion: 1 }) + }, + async request(method, path, body) { + calls.push({ method, path, body: structuredClone(body) }) + if (path.endsWith('/loading')) { + if (method === 'GET') return ok(loading()) + if (String(body.recordVersion) !== String(trip.recordVersion) && fault !== 'accept-old-version') { + if (fault === 'reject-but-write') load = { manifestHash: body.manifestHash, pallets: body.pallets } + return { http: 200, body: { code: 'A0409', message: fault === 'unrelated-conflict' ? '权限未配置' : '车次已被修改,请刷新后重试' } } + } + load = { manifestHash: body.manifestHash, pallets: body.pallets, cargo: currentCargo } + if (assigned && fault === 'wrong-final-measurement') load.pallets = [{ ...body.pallets[0], weightKg: 200 }] + trip.recordVersion++; return ok(loading()) + } + if (path.endsWith('/master-data/vehicles')) { + assert.equal(body.status, 'IN_SERVICE'); assert.equal(body.maxLoadKg, trip.maxLoadKg) + return ok({ id: '102' }) + } + if (path.endsWith('/drivers')) return ok({ id: '202', recordVersion: 0 }) + if (path.includes('/dispatches/') && path.endsWith('/review')) { + assert.equal(String(body.recordVersion), String(trip.recordVersion)); trip.recordVersion++ + dispatches[0].review = { status: 'APPROVED' }; return ok({ trip, dispatches }) + } + if (path.endsWith('/loading-review')) { + assert.equal(String(body.recordVersion), String(trip.recordVersion)); assert.ok(load); trip.recordVersion++ + return ok({ trip, dispatches, loadingReview: { status: 'APPROVED' } }) + } + if (path.endsWith('/review')) return ok({ reviewStatus: 'APPROVED' }) + if (path.endsWith('/assignment')) { + assert.equal(String(body.recordVersion), String(trip.recordVersion)) + Object.assign(trip, { vehicleId: body.vehicleId, driverId: body.driverId, vehiclePlate: 'REPLACEMENT', driverName: 'Replacement driver', driverPhone: '456' }) + trip.recordVersion++; assigned = true; dispatches[0].review = { status: 'PENDING' } + if (fault !== 'keeps-old-load') load = null + Object.assign(dispatch, { vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone }) + if (fault === 'changes-picking') dispatch.pickedPackageCount = 0 + if (fault === 'changes-customer') currentCargo = [{ ...cargo(), customerId: 'B' }] + if (fault === 'unknown-assignment-outcome') throw new Error('connection interrupted after commit') + return ok({ trip, dispatches }) + } + if (path.endsWith(`/dispatches/${proof.dispatchNo}`)) return ok(dispatch) + if (path.endsWith(`/trips/${proof.tripNo}`)) return ok({ trip, dispatches }) + throw new Error(`unexpected request ${method} ${path}`) + }, + } + return { proof, calls, assertions, context, trip } +} + +test('fresh-run load checks reassignment, unchanged work, stale version rejection and fresh confirmation before returning departure version', async () => { + const server = reassignmentServer() + const version = await confirmFleetLoading(server.context, 'test', server.proof) + assert.equal(server.proof.reassignment.status, 'VERIFIED') + assert.equal(version, 9) + assert.equal(server.proof.vehicleId, '102'); assert.equal(server.proof.driverId, '202') + assert.equal(server.proof.reassignment.originalTrip.vehicleId, '101') + assert.equal(server.proof.reassignment.dispatchAfter.pickedPackageCount, 10) + assert.equal(server.proof.confirmedLoad.pallets[0].weightKg, 100) + assert.ok(server.assertions.every(row => row.passed)) + assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).length, 3) + assert.ok(server.calls.every(row => !/\/depart|\/pick|\/complete$/.test(row.path)), 'reassignment does not repeat physical warehouse work') +}) + +test('string-serialized versions compare numerically across 9 to 10 and remain unchanged in requests', async () => { + const server = reassignmentServer('string-versions') + assert.equal(await confirmFleetLoading(server.context, 'test', server.proof), '13') + assert.equal(server.proof.reassignment.status, 'VERIFIED') + assert.equal(server.proof.reassignment.assignmentRequest.recordVersion, '9') + assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).at(-1).body.recordVersion, '11') +}) + +for (const [fault, message] of [ + ['keeps-old-load', /原装载失效/], ['changes-picking', /保留拣货/], ['changes-customer', /客户归属/], + ['accept-old-version', /旧版本不能/], ['unrelated-conflict', /旧版本不能/], + ['reject-but-write', /拒绝后仍未确认/], ['wrong-final-measurement', /装载确认返回/], + ['unknown-assignment-outcome', /connection interrupted/], +]) test(`reassignment stops on ${fault} and retains requests without silently retrying`, async () => { + const server = reassignmentServer(fault) + await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), message) + assert.equal(server.proof.reassignment.status, 'CHECKING') + assert.equal(server.proof.reassignment.assignmentRequest.vehicleId, '102') + const callCount = server.calls.length + await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), /已开始/) + assert.equal(server.calls.length, callCount) +}) + +test('retained dispatch loading does not create replacement resources or mutate assignment', async () => { + const server = reassignmentServer() + delete server.proof.reassignment + const version = await confirmFleetLoading(server.context, 'test', server.proof) + assert.equal(version, 6) + assert.equal(server.calls.length, 3) + assert.ok(server.calls.every(row => (row.path.endsWith('/loading') || row.path.endsWith('/loading-review')))) +}) + +function closureServer(fault = '') { + const proof = { tripNo: 'TR-ORIGINAL', vehicleId: '9007199254740993', driverId: '9007199254740995', warehouseId: '92', stops } + const trips = new Map([[proof.tripNo, { tripNo: proof.tripNo, recordVersion: 5, vehicleId: proof.vehicleId, driverId: proof.driverId, + status: 'IN_TRANSIT', completedAt: null, vehicleRelease: null, driverRelease: null }]]) + const claims = new Map([[`VEHICLE:${proof.vehicleId}`, proof.tripNo], [`DRIVER:${proof.driverId}`, proof.tripNo]]) + const remembered = new Map(), calls = [], assertions = [] + let sequence = 0 + const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } }) + const fail = message => ({ http: 200, body: { code: 'A0409', message } }) + const context = { + marker: 'CONTROLLED-TEST', + assert(_module, label, _response, passed) { assertions.push({ label, passed }) }, + apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null }, + async request(method, path, body) { + calls.push({ method, path, body: structuredClone(body) }) + if (path.endsWith('/master-data/vehicles')) { + assert.equal(body.status, 'IN_SERVICE', 'the independent driver occupancy probe needs a valid in-service vehicle') + return ok({ id: 'SPARE-VEHICLE' }) + } + if (method === 'POST' && path.endsWith('/fleet/trips')) { + const tripNo = `TR-PROBE-${++sequence}` + const trip = { tripNo, recordVersion: 0, status: 'PLANNED', vehicleId: null, driverId: null } + trips.set(tripNo, trip); return ok({ trip }) + } + const match = path.match(/\/trips\/([^/]+)(.*)$/), trip = trips.get(match?.[1]), suffix = match?.[2] + assert.ok(trip, `unexpected request ${path}`) + if (method === 'GET' && suffix === '/completion') return ok({ recordVersion: trip.recordVersion, canComplete: true, outcomes: [cargo()] }) + if (method === 'GET' && !suffix) return ok({ trip }) + if (suffix === '/complete' || suffix === '/resources/release') { + const key = `${trip.tripNo}:${body.idempotencyKey}` + if (remembered.has(key)) { + assert.deepEqual(body, remembered.get(key), 'retry must preserve exact original body') + if (fault === 'old-release-clears-new-claim' && suffix === '/resources/release') claims.delete(`${body.resourceKind}:${body.resourceKind === 'VEHICLE' ? trip.vehicleId : trip.driverId}`) + return ok({ trip }) + } + assert.equal(body.recordVersion, trip.recordVersion) + remembered.set(key, structuredClone(body)); trip.recordVersion++ + if (suffix === '/complete') { trip.status = 'COMPLETED'; trip.completedAt = '2026-10-02T00:00:00Z' } + else { + const vehicle = body.resourceKind === 'VEHICLE' + claims.delete(`${body.resourceKind}:${vehicle ? trip.vehicleId : trip.driverId}`) + trip[vehicle ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation, vehicleStatus: body.vehicleStatus } + if (fault === 'vehicle-also-frees-driver' && vehicle) claims.delete(`DRIVER:${trip.driverId}`) + } + return ok({ trip }) + } + if (suffix === '/assignment') { + if (fault === 'unrelated-conflict') return fail('车次版本已变化') + if (claims.has(`VEHICLE:${body.vehicleId}`)) return fail('车辆已被其他车次占用') + if (claims.has(`DRIVER:${body.driverId}`)) return fail('司机已被其他车次占用') + assert.equal(body.recordVersion, trip.recordVersion) + trip.vehicleId = body.vehicleId; trip.driverId = body.driverId; trip.recordVersion++ + claims.set(`VEHICLE:${body.vehicleId}`, trip.tripNo); claims.set(`DRIVER:${body.driverId}`, trip.tripNo) + return ok({ trip }) + } + if (suffix === '/cancel') { + assert.equal(body.recordVersion, trip.recordVersion) + for (const [key, owner] of claims) if (owner === trip.tripNo) claims.delete(key) + trip.status = 'CANCELLED'; trip.recordVersion++; return ok({ trip }) + } + throw new Error(`unhandled request ${method} ${path}`) + }, + } + return { proof, trips, claims, calls, assertions, context } +} + +test('closure proves independent resource occupancy through real command-shaped probes, then cancels empty plans', async () => { + const server = closureServer() + const result = await finishFleetTrip(server.context, 'test', server.proof) + assert.equal(result.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + assert.equal(server.claims.size, 0) + assert.ok(server.assertions.every(row => row.passed)) + assert.equal(result.probes.length, 2) + assert.ok(result.probes.every(row => row.finalStatus === 'CANCELLED')) + assert.equal(server.calls.filter(row => row.path.endsWith('/resources/release')).length, 4) + assert.equal(server.calls.filter(row => row.path.endsWith('/assignment')).length, 5) + assert.ok(server.calls.every(row => !/\/depart|\/items\/.*\/pick/.test(row.path)), 'closure never replays physical departure or inventory work') + await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /已开始/) +}) + +test('closure fails if ending only the vehicle silently releases the driver too', async () => { + const server = closureServer('vehicle-also-frees-driver') + await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /司机仍不能/) + assert.equal(server.proof.closure.status, 'CHECKING') + assert.ok(server.assertions.some(row => row.passed === false)) +}) + +test('closure detects an old release command deleting a subsequent trip claim', async () => { + const server = closureServer('old-release-clears-new-claim') + await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /旧结束重放不能释放/) + assert.notEqual(server.proof.closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') +}) + +test('an unrelated conflict cannot stand in for proof of occupied resources', async () => { + const server = closureServer('unrelated-conflict') + await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /未证明资源占用/) + assert.equal(server.calls.some(row => row.path.endsWith('/resources/release')), false) +}) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs new file mode 100644 index 000000000..9bfd17468 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -0,0 +1,296 @@ +import { createHash, randomUUID } from 'node:crypto' + +const BASE = '/api/platform/v1/last-leg/fleet' +const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const version = value => { + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) + throw new Error('服务端车次版本无效') + return BigInt(String(value)) +} + +async function command(context, module, method, path, body, label) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(module, label, response, '', OPTIONS) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`) + return data +} + +/** Test-only resources are created through real APIs and retained in the run report. No business SQL. */ +export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) { + if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓') + const marker = `FT-${randomUUID().slice(0, 12)}` + const proof = { dispatchNo, warehouseId: id(warehouseId), marker, reassignment: { status: 'PENDING' } } + context.createdResources.fleetTrips ??= [] + context.createdResources.fleetTrips.push(proof) + const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { + plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, + region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, + remark: `${context.marker} controlled TEST vehicle`, + }, '建立本轮真实车辆档案及容量') + proof.vehicleId = id(vehicle.id) + await prepareQualifiedDriver(context, module, marker, proof) + const plannedDepartureAt = new Date(Date.now() + 3600000).toISOString() + const expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() + const createKey = createHash('sha256').update(`${context.marker}:${dispatchNo}`).digest('hex') + let detail = await command(context, module, 'POST', `${BASE}/trips`, { + warehouseId, plannedDepartureAt, expectedDriverEndAt, idempotencyKey: createKey, + stops: [{ name: '测试起运仓' }, { name: '待匹配货物目的地' }], remark: context.marker, + }, '创建本轮共享运输车次') + proof.tripNo = detail.trip.tripNo + const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` + detail = await command(context, module, 'POST', `${path}/assignment`, { + recordVersion: detail.trip.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '本轮测试派车', + }, '正式分配车辆司机并核验证照') + detail = await command(context, module, 'POST', `${path}/dispatches/bind`, { + recordVersion: detail.trip.recordVersion, dispatchNo, reason: '关联本轮客户执行单据', + }, '将客户排车单关联至车次') + const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '读取真实货物目的地匹配键') + const destinations = new Map() + for (const cargo of loading.cargo || []) { + if (!cargo.destinationKey) throw new Error('实际排车货物缺少目的地,不能伪造装载站点') + destinations.set(cargo.destinationKey, { name: cargo.destinationLabel, destinationKey: cargo.destinationKey }) + } + if (!destinations.size) throw new Error('实际排车没有货物') + detail = await command(context, module, 'PUT', `${path}/plan`, { + recordVersion: detail.trip.recordVersion, plannedDepartureAt, expectedDriverEndAt, + stops: [{ name: '测试起运仓' }, ...destinations.values()], reason: '按实际货物目的地冻结路线', + }, '在拣货前匹配真实卸货站') + proof.stops = detail.trip.stops + detail = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(dispatchNo)}/review`, { + recordVersion: detail.trip.recordVersion, decision: 'APPROVED', + }, '拣货前通过本轮客户排车调度审核') + proof.dispatchReview = detail.dispatches.find(row => row.dispatchNo === dispatchNo)?.review + if (proof.dispatchReview?.status !== 'APPROVED') throw new Error('客户排车调度审核未通过,停止拣货准备') + return proof +} + +async function prepareQualifiedDriver(context, module, marker, proof) { + let driver = await command(context, module, 'POST', `${BASE}/drivers`, { + name: `${marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', + licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), + remark: `${context.marker} TEST credential fixture; not a real driving licence`, + }, '建立本轮司机证照档案') + proof.driverId = id(driver.id) + const uploaded = await context.requestMultipart('POST', `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, { + name: `${marker}-TEST-LICENSE.png`, type: 'image/png', + content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'), + }) + driver = context.apiSuccess(module, '上传本轮测试证照文件', uploaded, '', OPTIONS) + if (!driver?.licenseFileId) throw new Error('测试证照上传失败,不允许伪造已审核资格') + proof.licenseFileId = id(driver.licenseFileId) + const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, { + recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅用于本轮受控测试司机', + }, '通过正式命令审核测试司机') + if (reviewed.reviewStatus !== 'APPROVED') throw new Error('测试司机未审核通过,停止派车验证') +} + +export function controlledPalletMeasurements(cargo, stops) { + const pallets = new Map() + for (const item of cargo) { + if (!item.warehousePalletId || !item.palletNo || !item.destinationKey) throw new Error('装载货物缺少真实托盘或目的地') + const deliveryIndex = stops.findIndex(stop => stop.destinationKey === item.destinationKey) + if (deliveryIndex < 1) throw new Error('货物目的地不在车次路线') + const key = id(item.warehousePalletId) + if (pallets.has(key) && pallets.get(key).deliveryIndex !== deliveryIndex) throw new Error('混合目的地实物托盘须先拆托') + // Controlled test measurement, deliberately separate from source billing/allocated mass. + pallets.set(key, { warehousePalletId: key, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex }) + } + if (!pallets.size) throw new Error('没有实际装载托盘') + return [...pallets.values()] +} + +export async function confirmFleetLoading(context, module, proof) { + const state = proof.reassignment + if (state && state.status !== 'PENDING') throw new Error('本轮改派检查已开始;保留原请求排查,不自动重复写入') + if (state) state.status = 'CHECKING' + const confirmed = await confirmActualLoad(context, module, proof) + if (!state) return approveLoading(context, module, proof, confirmed.recordVersion) + state.originalConfirmation = confirmed + await verifyPreDepartureReassignment(context, module, proof, confirmed) + const reconfirmed = await confirmActualLoad(context, module, proof) + check(context, module, '重新确认仍覆盖改派前同批货物', reconfirmed.manifestHash === confirmed.manifestHash + && JSON.stringify(reconfirmed.cargo) === JSON.stringify(confirmed.cargo) + && version(reconfirmed.recordVersion) > version(state.assignedTrip.recordVersion)) + state.finalConfirmation = reconfirmed + const reviewedVersion = await approveLoading(context, module, proof, reconfirmed.recordVersion) + state.status = 'VERIFIED' + return reviewedVersion +} + +async function approveLoading(context, module, proof, recordVersion) { + const detail = await command(context, module, 'POST', `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading-review`, { + recordVersion, decision: 'APPROVED', + }, '实际装载确认后通过整车理货审核') + proof.loadingReview = detail.loadingReview + if (detail.loadingReview?.status !== 'APPROVED') throw new Error('整车理货审核未通过,不允许发车') + proof.recordVersion = detail.trip.recordVersion + return proof.recordVersion +} + +async function confirmActualLoad(context, module, proof) { + const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading` + const loading = await command(context, module, 'GET', path, undefined, '拣货后读取整车实际装载依据') + if (loading.blockReason) throw new Error(`整车未就绪:${loading.blockReason}`) + const pallets = controlledPalletMeasurements(loading.cargo, proof.stops) + const confirmed = await command(context, module, 'POST', path, { + recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets, + reason: '受控测试测量:每个实物托盘1托位、100kg、1m³;非生产测量', + }, '通过正式命令确认受控测试装载') + proof.confirmedLoad = confirmed.confirmedLoad + proof.recordVersion = confirmed.recordVersion + check(context, module, '装载确认返回当前明细与新版本', confirmed.confirmedLoad?.manifestHash === loading.manifestHash + && version(confirmed.recordVersion) > version(loading.recordVersion) + && JSON.stringify(confirmed.confirmedLoad?.pallets) === JSON.stringify([...pallets].sort((a, b) => a.warehousePalletId.localeCompare(b.warehousePalletId)))) + return confirmed +} + +function dispatchWorkSnapshot(row) { + return JSON.stringify({ dispatchNo: row.dispatchNo, warehouseId: row.overseasWarehouseId, + status: row.status, packageCount: row.packageCount, pickedPackageCount: row.pickedPackageCount, + shippedPackageCount: row.shippedPackageCount, remainingPackageCount: row.remainingPackageCount, + estimatedArrivalAt: row.estimatedArrivalAt, items: row.items }) +} + +async function verifyPreDepartureReassignment(context, module, proof, confirmed) { + const state = proof.reassignment + const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` + const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}` + const original = await command(context, module, 'GET', path, undefined, '改派前读取车次资源和计划') + const before = await command(context, module, 'GET', dispatchPath, undefined, '改派前保留实际拣货与货物明细') + check(context, module, '改派仅针对本轮已拣完但未发车的车次', original.trip?.status === 'PLANNED' + && version(original.trip.recordVersion) === version(confirmed.recordVersion) && before.status === 'READY_TO_DEPART' + && before.dispatchNo === proof.dispatchNo && before.pickedPackageCount > 0 && before.shippedPackageCount === 0 + && before.pickedPackageCount === before.packageCount && before.items?.length > 0 + && id(original.trip.vehicleId) === proof.vehicleId && id(original.trip.driverId) === proof.driverId) + state.originalTrip = original.trip; state.dispatchBefore = before + const marker = `SWAP-${randomUUID().slice(0, 12)}` + const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { + plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, + region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: original.trip.maxPalletSpaces, + maxLoadKg: original.trip.maxLoadKg, maxVolumeCbm: original.trip.maxVolumeCbm, remark: `${context.marker} 发车前改派测试`, + }, '创建本轮等容量替换车辆') + state.replacement = { vehicleId: id(vehicle.id) } + await prepareQualifiedDriver(context, module, marker, state.replacement) + state.assignmentRequest = { recordVersion: confirmed.recordVersion, vehicleId: state.replacement.vehicleId, + driverId: state.replacement.driverId, reason: '已拣完发车前换车换司机并重新核验装载' } + const assigned = await command(context, module, 'POST', `${path}/assignment`, state.assignmentRequest, '已拣货车次正式更换车辆和司机') + state.assignedTrip = assigned.trip + check(context, module, '改派完成且保持路线与客户关联', id(assigned.trip?.vehicleId) === state.replacement.vehicleId + && id(assigned.trip?.driverId) === state.replacement.driverId && assigned.trip.status === 'PLANNED' + && version(assigned.trip.recordVersion) > version(confirmed.recordVersion) && JSON.stringify(assigned.trip.stops) === JSON.stringify(original.trip.stops) + && JSON.stringify(assigned.dispatches.map(({ review, ...row }) => row)) === JSON.stringify(original.dispatches.map(({ review, ...row }) => row)) + && assigned.dispatches.every(row => row.review?.status === 'PENDING')) + const after = await command(context, module, 'GET', dispatchPath, undefined, '改派后回查实际拣货与货物明细') + state.dispatchAfter = after + check(context, module, '改派只更新车辆司机并保留拣货货量到达时间', dispatchWorkSnapshot(after) === dispatchWorkSnapshot(before) + && after.vehiclePlate === assigned.trip.vehiclePlate && after.driverName === assigned.trip.driverName && after.driverPhone === assigned.trip.driverPhone) + const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '回查改派后原装载确认失效') + check(context, module, '原装载失效但货物和客户归属保持一致', loading.confirmedLoad == null + && version(loading.recordVersion) === version(assigned.trip.recordVersion) && loading.manifestHash === confirmed.manifestHash + && JSON.stringify(loading.cargo) === JSON.stringify(confirmed.cargo)) + state.staleLoadRequest = { recordVersion: confirmed.recordVersion, manifestHash: confirmed.manifestHash, + pallets: confirmed.confirmedLoad.pallets, reason: '旧改派前页面提交必须被拒绝' } + const stale = await context.request('POST', `${path}/loading`, state.staleLoadRequest) + const conflict = stale?.http === 409 || (stale?.http === 200 && ['409', 'A0409'].includes(String(stale?.body?.code))) + state.staleLoadResult = { http: stale?.http, code: stale?.body?.code, message: stale?.body?.message } + check(context, module, '旧版本不能恢复旧装载确认', conflict && /已被修改/.test(String(stale?.body?.message || ''))) + const unchanged = await command(context, module, 'GET', `${path}/loading`, undefined, '拒绝旧版本后回查装载未发生写入') + check(context, module, '旧版本拒绝后仍未确认且版本保持不变', version(unchanged.recordVersion) === version(assigned.trip.recordVersion) + && unchanged.confirmedLoad == null && unchanged.manifestHash === loading.manifestHash) + let reviewed = assigned + for (const binding of assigned.dispatches) { + reviewed = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, { + recordVersion: reviewed.trip.recordVersion, decision: 'APPROVED', + }, '换车换司机后重新通过调度审核') + if (reviewed.dispatches.find(row => row.dispatchNo === binding.dispatchNo)?.review?.status !== 'APPROVED') throw new Error('改派后调度审核未通过') + } + state.reviewedDispatches = reviewed.dispatches + proof.vehicleId = state.replacement.vehicleId; proof.driverId = state.replacement.driverId + proof.licenseFileId = state.replacement.licenseFileId +} + +function check(context, module, label, condition, evidence = '') { + context.assert(module, label, null, condition, evidence, OPTIONS) + if (!condition) throw new Error(`${module}: ${label}失败`) +} + +async function expectOccupied(context, module, path, body, resource, label) { + const response = await context.request('POST', `${path}/assignment`, body) + const conflict = response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code))) + const message = String(response?.body?.message || '') + const valid = conflict && message.includes(resource) && message.includes('占用') + context.assert(module, label, response, valid, `expected occupied ${resource}`, OPTIONS) + if (!valid) throw new Error(`${module}: ${label}未证明资源占用;停止并保留本轮资源`) +} + +/** Fresh-run closure proof only. Retain all commands on proof for diagnostics; do not repair old fixtures. */ +export async function finishFleetTrip(context, module, proof) { + if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明') + if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造或改写请求') + const state = proof.closure = { status: 'CHECKING', probes: [] } + const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` + const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车所有货物结案条件') + check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '') + state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' } + const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案车次货物') + check(context, module, '结案记录原运输完成时间且尚未声明资源释放', completed.trip?.status === 'COMPLETED' + && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null) + const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '同键重放货物结案') + check(context, module, '结案重放没有新增版本或改变完成时间', replay.trip?.recordVersion === completed.trip.recordVersion && replay.trip.completedAt === completed.trip.completedAt) + + async function probe(index) { + const request = { warehouseId: proof.warehouseId, idempotencyKey: `${proof.tripNo}:RELEASE-PROBE:${index}`, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), + stops: proof.stops, remark: '本轮资源占用验收计划,不关联货物或执行发车' } + const evidence = { createRequest: request }; state.probes.push(evidence) + const created = await command(context, module, 'POST', `${BASE}/trips`, request, `建立资源占用核对计划${index}`) + evidence.tripNo = created.trip.tripNo; evidence.recordVersion = created.trip.recordVersion + return { evidence, path: `${BASE}/trips/${encodeURIComponent(evidence.tripNo)}` } + } + const first = await probe(1) + const assign = { recordVersion: first.evidence.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '验证本轮资源占用' } + await expectOccupied(context, module, first.path, assign, '车辆', '货物结案后车辆仍不能被另一车次占用') + + const endLocation = proof.stops?.at(-1)?.name + check(context, module, '测试任务具有明确结束地点', Boolean(endLocation)) + // These controlled fixtures end at the signed final stop and contain no return-driving task. + state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', + actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: 'IN_SERVICE', + idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: '受控测试车辆在末站结束,无返程安排' } + const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '单独结束测试车辆任务') + check(context, module, '只释放车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null) + await expectOccupied(context, module, first.path, assign, '司机', '车辆已释放但司机仍不能被另一车次占用') + + state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', + actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: null, + idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: '受控测试司机在末站结束,无返程安排' } + const driverReleased = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '单独结束测试司机任务') + check(context, module, '车辆和司机各自保留真实任务结束记录', Boolean(driverReleased.trip?.vehicleRelease && driverReleased.trip.driverRelease)) + const assigned = await command(context, module, 'POST', `${first.path}/assignment`, assign, '释放后正式再次分配相同车辆司机') + first.evidence.recordVersion = assigned.trip.recordVersion + check(context, module, '释放后的两项资源可被下一计划使用', id(assigned.trip?.vehicleId) === id(proof.vehicleId) && id(assigned.trip?.driverId) === id(proof.driverId)) + const oldVehicleReplay = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '资源再分配后重放旧车辆结束请求') + const oldDriverReplay = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '资源再分配后重放旧司机结束请求') + check(context, module, '原结束请求重放不增加旧车次版本', oldVehicleReplay.trip.recordVersion === driverReleased.trip.recordVersion && oldDriverReplay.trip.recordVersion === driverReleased.trip.recordVersion) + const second = await probe(2) + await expectOccupied(context, module, second.path, { ...assign, recordVersion: second.evidence.recordVersion }, '车辆', '旧结束重放不能释放下一计划的车辆占用') + // A different free fixture vehicle isolates the driver claim from the vehicle claim. + const spare = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { + plateNumber: `PROBE-${randomUUID().slice(0,12)}`, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, + region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} 仅用于本轮司机占用负向验证`, + }, '建立司机占用隔离用测试车辆') + state.spareVehicleId = id(spare.id) + await expectOccupied(context, module, second.path, { ...assign, vehicleId: state.spareVehicleId, recordVersion: second.evidence.recordVersion }, '司机', '旧结束重放不能释放下一计划的司机占用') + for (const item of [second, first]) { + const cancelled = await command(context, module, 'POST', `${item.path}/cancel`, { recordVersion: item.evidence.recordVersion, reason: '资源占用核对完成,取消无货测试计划' }, '正式取消无货核对计划') + check(context, module, '核对计划取消且保留历史', cancelled.trip?.status === 'CANCELLED') + item.evidence.finalStatus = cancelled.trip.status + } + const final = await command(context, module, 'GET', path, undefined, '回查最终车次结案与资源结束记录') + check(context, module, '车次结案及两项资源结束保持一致', final.trip?.status === 'COMPLETED' && Boolean(final.trip.vehicleRelease && final.trip.driverRelease)) + state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' + state.finalTrip = final.trip + return state +} diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 94b20a224..263728507 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -1,13 +1,29 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs' +import { attachDeliveredOrderFixture, missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs' import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8') const loadingSource = readFileSync(new URL('./manual-state-loading-pda.mjs', import.meta.url), 'utf8') +test('DELIVERED 必须回查本轮已签收但根未完成的正式订单', async () => { + const matrix = { pages: { unifiedOrders: { fixtures: {} } } } + const context = { marker: 'run-1', createdResources: { phase5OverseasFulfillment: { marker: 'run-1', + orderCompletionProof: { customerId: '7', rootOrderId: '99', rootDelivered: true, expectedCompleted: false } } }, + request: async (method, path) => { + assert.equal(method, 'GET') + assert.equal(path, '/api/platform/v1/orders/99?tenantId=7') + return { body: { data: { summary: { orderId: '99', mainStatus: 'DELIVERED' } } } } + }, apiSuccess: (_m, _n, response) => response.body.data, + assert: (_m, _n, _r, valid) => assert.equal(valid, true) } + await attachDeliveredOrderFixture(context, matrix) + assert.equal(matrix.pages.unifiedOrders.fixtures.DELIVERED.id, '99') + context.createdResources.phase5OverseasFulfillment.marker = 'old' + await assert.rejects(attachDeliveredOrderFixture(context, matrix), /本轮/) +}) + test('default full acceptance includes the retained manual state matrix after lifecycle modules', () => { assert.match(entry, /'manual-state-matrix'/) assert.ok(entry.indexOf("'phase3-order-splitting'") < entry.indexOf("'manual-state-matrix'")) @@ -31,6 +47,13 @@ test('focused matrix execution injects real container lifecycle prerequisites an 'service-center-flow', 'phase3-outbound-dispatch', 'phase3-container-documents', + 'phase3-carrier-schedule-queries', + 'phase4-carrier-pickup-ready', + 'phase4-tail-order-controls', + 'phase4-overseas-pda', + 'phase4-overseas-pda-operations', + 'phase4-overseas-dispatch', + 'phase5-overseas-fulfillment', 'manual-state-matrix', ] assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected) @@ -57,6 +80,13 @@ test('focused splitting and manual groups resolve producers before consumers wit 'service-center-flow', 'phase3-outbound-dispatch', 'phase3-container-documents', + 'phase3-carrier-schedule-queries', + 'phase4-carrier-pickup-ready', + 'phase4-tail-order-controls', + 'phase4-overseas-pda', + 'phase4-overseas-pda-operations', + 'phase4-overseas-dispatch', + 'phase5-overseas-fulfillment', 'manual-state-matrix', ] assert.deepEqual(normalizeModuleOrder([ @@ -92,10 +122,10 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem assert.deepEqual(matrix.pages.inbounds.supportedStatuses, ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE']) - assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED']) + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') - assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable') - assert.deepEqual(missingRequiredStateFixtures(matrix), []) + assert.equal(matrix.pages.unifiedOrders.coverageGaps, undefined) + assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:DELIVERED']) assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/) }) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 8389341fd..a0230ad0e 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -15,9 +15,6 @@ const FIXTURE_UNAVAILABLE = { INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API', VOIDED: '当前 Controller/Service 没有作废入库单的公开 API', }, - unifiedOrders: { - DELIVERED: '当前后端没有完成派送并写入 DELIVERED 的公开业务 API', - }, } function rows(value) { return Array.isArray(value) ? value : [] } @@ -46,6 +43,25 @@ export function missingRequiredStateFixtures(matrix) { .map((status) => `${page}:${status}`)) } +export async function attachDeliveredOrderFixture(context, matrix) { + const fulfilled = context.createdResources?.phase5OverseasFulfillment + const proof = fulfilled?.orderCompletionProof + const customerId = id(proof?.customerId) + const orderId = id(proof?.rootOrderId) + if (fulfilled?.marker !== context.marker || !customerId || !orderId || !proof?.rootDelivered || proof.expectedCompleted !== false) { + throw new Error('DELIVERED 样本须复用本轮已签收且仍有有效余量的履约根') + } + const response = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(customerId)}`) + const detail = context.apiSuccess(MODULE, '回读真实签收产生的 DELIVERED 统一订单', response, '', OPTIONS) + const valid = id(detail?.summary?.orderId) === orderId && detail?.summary?.mainStatus === 'DELIVERED' + context.assert(MODULE, 'DELIVERED 样本客户与真实根签收状态正确', response, valid, + `orderId=${orderId}, status=${detail?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (!valid) throw new Error('DELIVERED 履约根详情未收敛到真实签收状态') + matrix.pages.unifiedOrders.fixtures.DELIVERED = { id: orderId, status: 'DELIVERED', + customerId, detailPath: `/orders/${orderId}` } +} + function registerFixtureUnavailableGaps(context, matrix) { for (const [page, value] of Object.entries(matrix?.pages || {})) { for (const [status, gap] of Object.entries(value?.coverageGaps || {})) { @@ -102,6 +118,7 @@ export async function run(context) { } await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED') + await attachDeliveredOrderFixture(context, matrix) normalizeStateRequirements(matrix) registerFixtureUnavailableGaps(context, matrix) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 0e2354f5e..e572094bc 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -3,8 +3,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' - -import { transition } from './order-inbound-status-fixtures.mjs' +import { createCompletedOrder, transition } from './order-inbound-status-fixtures.mjs' test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{ const calls=[] @@ -21,6 +20,26 @@ test('dynamic Platform cancellation freezes version and key while reject body st const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8') +test('完成样本来自真实履约编排且缺证据或客户漂移均阻断', async () => { + const context = { context: { phase4ReceivingEvidenceReader() {} }, createdResources: { orders: { orderId: '11' } } } + const prepare = async scope => { scope.context.merchantQualificationFixture = { tenantId: '7' } } + const run = proof => async scope => { + assert.equal(scope.context.f40SignatureOrder, 'ROOT_FIRST') + assert.equal(scope.context.phase4ReceivingEvidenceReader, context.context.phase4ReceivingEvidenceReader) + scope.createdResources.phase5RootFulfillment = proof + } + assert.deepEqual(await createCompletedOrder(context, run({ finalStatus: 'VERIFIED', customerId: '7', rootOrderId: '99' }), prepare), + { orderId: '99', tenantId: '7' }) + assert.equal(context.createdResources.orders.orderId, '11') + assert.equal(context.createdResources.phase5RootFulfillment, undefined) + for (const proof of [{ finalStatus: 'HTTP_VERIFIED_OUTBOX_PENDING', customerId: '7', rootOrderId: '99' }, + { finalStatus: 'VERIFIED', customerId: '8', rootOrderId: '99' }]) { + await assert.rejects(createCompletedOrder(context, run(proof), prepare), /真实签收/) + } + assert.doesNotMatch(source, /'start', 'complete'/) + assert.match(source, /phase5-root-fulfillment\.mjs/) +}) + test('为头程订单列表八个真实状态分别创建独立样本', () => { for (const status of ['DRAFT', 'SUBMITTED', 'REJECTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED', 'CANCELLED']) { assert.match(source, new RegExp(`\\['${status}', \\[`)) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index c30085dd9..14b04b80f 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -12,7 +12,7 @@ const ORDER_TARGETS = [ ['APPROVED', ['submit', 'approve']], ['ACCEPTED', ['submit', 'approve', 'accept']], ['PROCESSING', ['submit', 'approve', 'accept', 'start']], - ['COMPLETED', ['submit', 'approve', 'accept', 'start', 'complete']], + ['COMPLETED', []], ['CANCELLED', ['cancel']], ] const INBOUND_FILTER_STATUSES = [ @@ -135,6 +135,27 @@ export async function transition(context, token, fixture, orderId, target, actio context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response) } +export async function createCompletedOrder(context, runFulfillment = async scope => { + const { run } = await import('./phase5-root-fulfillment.mjs') + return run(scope) +}, prepareCustomer = async scope => { + const { run } = await import('./merchant-qualification-fixture.mjs') + return run(scope, { publishEnvironment: false }) +}) { + const scope = Object.create(context) + scope.marker = `${context.marker}-COMPLETED` + scope.context = { ...context.context, f40SignatureOrder: 'ROOT_FIRST' } + scope.createdResources = { ...context.createdResources } + await prepareCustomer(scope) + try { await runFulfillment(scope) } + finally { context.createdResources.manualCompletedOrder = scope.createdResources.phase5RootFulfillment } + const proof = scope.createdResources.phase5RootFulfillment + if (proof?.finalStatus !== 'VERIFIED' + || id(proof.customerId) !== id(scope.context.merchantQualificationFixture?.tenantId) + || !id(proof.rootOrderId)) throw new Error('COMPLETED 样本必须由同客户真实签收及完成Outbox验证产生') + return { orderId: id(proof.rootOrderId), tenantId: id(proof.customerId) } +} + async function readOrder(context, fixture, orderId) { const response = await context.request('GET', `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`) @@ -248,16 +269,18 @@ export async function run(context, matrix = context.createdResources.manualState matrix.pages.unifiedOrders = { requiredStatuses: UNIFIED_FILTER_STATUSES, fixtures: {} } for (const [target, actions] of ORDER_TARGETS) { - const orderId = await createOrder(context, token, fixture, target) - for (const action of actions) await transition(context, token, fixture, orderId, target, action) - const { response, detail } = await readOrder(context, fixture, orderId) + const completed = target === 'COMPLETED' ? await createCompletedOrder(context) : null + const activeFixture = completed ? { ...fixture, tenantId: completed.tenantId } : fixture + const orderId = completed?.orderId || await createOrder(context, token, activeFixture, target) + for (const action of actions) await transition(context, token, activeFixture, orderId, target, action) + const { response, detail } = await readOrder(context, activeFixture, orderId) context.assert(MODULE, `${target} 订单状态样本可从详情打开`, response, id(detail?.order?.id) === orderId && detail?.order?.status === target, `orderId=${orderId}, status=${detail?.order?.status ?? 'missing'}`) - result.orders[target] = { orderId, orderNo: detail?.order?.orderNo || null, status: target, + result.orders[target] = { orderId, tenantId: activeFixture.tenantId, orderNo: detail?.order?.orderNo || null, status: target, listPath: `/admin/first-leg-orders?status=${target}` } matrix.pages.firstLegOrders.fixtures[target] = { - id: orderId, status: target, detailPath: `/orders/${orderId}`, + id: orderId, tenantId: activeFixture.tenantId, status: target, detailPath: `/orders/${orderId}`, } if (actions.includes('approve')) { @@ -274,7 +297,7 @@ export async function run(context, matrix = context.createdResources.manualState for (const [status, sample] of Object.entries(result.orders)) { const response = await context.request('GET', - `/api/platform/v1/orders/first-leg?tenantId=${query(fixture.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`) + `/api/platform/v1/orders/first-leg?tenantId=${query(sample.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`) const page = context.apiSuccess(MODULE, `${status} 订单可按列表状态筛选`, response) context.assert(MODULE, `${status} 筛选结果包含人工样本`, response, rows(page).some((item) => id(item.id) === sample.orderId), `orderId=${sample.orderId}, total=${page?.total ?? 'n/a'}`) diff --git a/scripts/admin-test-env-modules/orders-session-contract.test.mjs b/scripts/admin-test-env-modules/orders-session-contract.test.mjs new file mode 100644 index 000000000..5096ded0d --- /dev/null +++ b/scripts/admin-test-env-modules/orders-session-contract.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { run } from './orders.mjs' + +async function loginOnly(fixture, options = {}) { + const calls = [] + const context = { context: { merchantQualificationFixture: fixture }, options, + request: async (...args) => { calls.push(args); return {} }, apiSuccess: () => null } + await assert.rejects(run(context), /Client 登录未返回 accessToken/) + return calls +} +test('one cached orders module uses each serial run customer, never the first customer', async () => { + const first = await loginOnly({ username: 'customer-a', password: 'password-a' }) + const second = await loginOnly({ username: 'customer-b', password: 'password-b' }, + { merchantUsername: 'customer-a', merchantPassword: 'password-a' }) + assert.deepEqual(first[0].slice(0,3), ['POST','/api/auth/login',{ loginId:'customer-a',password:'password-a' }]) + assert.deepEqual(second[0].slice(0,3), ['POST','/api/auth/login',{ loginId:'customer-b',password:'password-b' }]) + assert.equal(first.length, 1); assert.equal(second.length, 1) +}) +test('partial current fixture never combines credentials with configured prior customer', async () => { + let requests = 0 + await assert.rejects(run({ context:{merchantQualificationFixture:{username:'customer-b'}}, + options:{merchantUsername:'customer-a',merchantPassword:'password-a'}, + request:async()=>{requests++} }), /缺少完整登录凭据/) + assert.equal(requests, 0) +}) +test('explicit paired options are used when no fixture was created', async () => { + const calls = await loginOnly(undefined,{merchantUsername:'configured',merchantPassword:'configured-password'}) + assert.equal(calls[0][2].loginId,'configured') +}) diff --git a/scripts/admin-test-env-modules/phase3-carrier-exceptions-contract.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-exceptions-contract.test.mjs new file mode 100644 index 000000000..d8011e3f1 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-carrier-exceptions-contract.test.mjs @@ -0,0 +1,102 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { CARRIER_EXCEPTION_CODES, carrierExceptionShapeErrors, carrierExceptionTransitionErrors, + captureCarrierExceptionEvidence } from './phase3-carrier-schedule-queries.mjs' + +const fixture = () => ({ marker: 'current', queryId: '2105227585104384001', detail: { + query: { query: { id: '2105227585104384001', customerId: '0', oceanBookingId: '2105227599885111297', + containerBookingId: '2105227585104384002', latestStandardNode: 'IN_TRANSIT', latestActualAt: '2026-10-02T09:00:00' } }, + nodes: [{ id: 'actual', standardNode: 'IN_TRANSIT', actualAt: '2026-10-02T09:00:00' }, + { id: 'estimated', standardNode: 'ARRIVED_DESTINATION_PORT', estimatedAt: '2026-10-20T09:00:00' }], + exceptions: [], histories: [], +} }) +const fact = (code, active = true, sequence = 1) => ({ id: `210522758510438401${sequence}`, + providerCode: 'TEST_PROVIDER', providerEventId: `${code}-${active}`, providerStatus: active ? 'HOLD' : 'RELEASE', + exceptionCode: code, active, observedAt: '2026-10-02T10:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', + sourceReference: 'controlled-provider', rawPayloadHash: (active ? 'a' : 'b').repeat(64), createdAt: '2026-10-02T10:00:01' }) +function append(before, value) { + const after = structuredClone(before) + after.detail.exceptions.push(value) + after.detail.histories.push({ id: value.id, action: 'EXCEPTION_OBSERVED', reason: value.exceptionCode }) + return after +} + +for (const code of CARRIER_EXCEPTION_CODES) { + test(`${code}: append and release retain original exception and both normal time facts`, () => { + const before = fixture(), active = fact(code), released = fact(code, false, 2) + const after = append(before, active) + assert.deepEqual(carrierExceptionTransitionErrors(before, after, { kind: 'APPEND', expected: active }), []) + assert.deepEqual(carrierExceptionTransitionErrors(after, append(after, released), { kind: 'RELEASE', expected: released }), []) + assert.deepEqual(carrierExceptionTransitionErrors(after, structuredClone(after), { kind: 'REPLAY', expected: active }), []) + assert.deepEqual(carrierExceptionTransitionErrors(after, structuredClone(after), { kind: 'CONFLICT', expected: { ...active, rawPayloadHash: 'c'.repeat(64) } }), []) + }) +} + +test('replay/conflict fail on changed fact, extra history, missing original event or wrong payload hash', () => { + const original = fact('CUSTOMS_HOLD'), before = append(fixture(), original) + for (const mutate of [ + value => { value.detail.exceptions[0].active = false }, + value => { value.detail.histories.push({ id: 'extra', action: 'EXCEPTION_OBSERVED' }) }, + value => { value.detail.nodes[1].estimatedAt = '2026-10-21T09:00:00' }, + value => { value.detail.query.query.latestStandardNode = 'PICKUP_READY' }, + value => { value.marker = 'other' }, + ]) { + const after = structuredClone(before); mutate(after) + assert.ok(carrierExceptionTransitionErrors(before, after, { kind: 'REPLAY', expected: original }).length) + } + assert.ok(carrierExceptionTransitionErrors(fixture(), fixture(), { kind: 'REPLAY', expected: original }).length) + assert.ok(carrierExceptionTransitionErrors(before, before, { kind: 'CONFLICT', expected: original }).length) +}) + +test('release replacing the active row, deleting original history or absent estimated baseline is rejected', () => { + const active = fact('TERMINAL_HOLD'), release = fact('TERMINAL_HOLD', false, 2) + const before = append(fixture(), active) + for (const mutate of [ + value => { value.detail.exceptions.shift() }, + value => { value.detail.histories.shift() }, + value => { value.detail.nodes.pop() }, + ]) { + const after = append(before, release); mutate(after) + assert.ok(carrierExceptionTransitionErrors(before, after, { kind: 'RELEASE', expected: release }).length) + } + const empty = fixture(); empty.detail.nodes.pop() + assert.ok(carrierExceptionTransitionErrors(empty, append(empty, active), { kind: 'APPEND', expected: active }).length) +}) + +test('reject customs declaration codes, duplicate supplier event, malformed hash and absent source fields', () => { + for (const mutate of [ + value => { value.detail.exceptions[0].exceptionCode = 'INSPECTION' }, + value => { value.detail.exceptions.push({ ...value.detail.exceptions[0], id: '2105227585104384019' }) }, + value => { value.detail.exceptions[0].rawPayloadHash = 'bad' }, + value => { value.detail.exceptions[0].providerCode = '' }, + value => { value.detail.nodes[0].standardNode = 'CUSTOMS_HOLD' }, + ]) { + const value = append(fixture(), fact('UNKNOWN_HOLD')); mutate(value) + assert.ok(carrierExceptionShapeErrors(value.detail).length) + } +}) + +test('provider DTO permits absent location and source reference; supplied values still match exactly', () => { + const active = { ...fact('UNKNOWN_HOLD'), locationCode: null, locationName: null, sourceReference: null } + const before = fixture(), after = append(before, structuredClone(active)) + assert.deepEqual(carrierExceptionShapeErrors(after.detail), []) + assert.deepEqual(carrierExceptionTransitionErrors(before, after, { kind: 'APPEND', expected: active }), []) + after.detail.exceptions[0].sourceReference = 'changed' + assert.ok(carrierExceptionTransitionErrors(before, after, { kind: 'APPEND', expected: active }).length) +}) + +test('real API checkpoint uses only original detail GET and rejects another marker before any call', async () => { + const before = fixture(), calls = [], original = globalThis.fetch + const context = { marker: 'current', options: { gateway: 'https://offline.invalid' }, token: 'offline', + apiSuccess: (_module, _name, response) => response.body.data, assert: (_module, _name, _response, passed) => assert.ok(passed) } + const resource = { queryId: before.queryId, marker: 'current', ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM' } + globalThis.fetch = async (url, options) => { calls.push({ url, method: options.method }); return new Response(JSON.stringify({ code: '200', data: before.detail })) } + try { + const captured = await captureCarrierExceptionEvidence(context, resource) + assert.deepEqual(captured.detail, before.detail) + assert.deepEqual(calls.map(item => item.method), ['GET']) + assert.ok(calls[0].url.endsWith(`/api/platform/v1/carrier-schedule-queries/${before.queryId}`)) + await assert.rejects(captureCarrierExceptionEvidence(context, { ...resource, marker: 'other' }), /current-marker/) + assert.equal(calls.length, 1) + } finally { globalThis.fetch = original } +}) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index f202c34fb..6995120d4 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -2,6 +2,91 @@ const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE']) +export const CARRIER_EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) + +function stable(value) { + if (Array.isArray(value)) return value.map(stable) + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) + return value +} +const equal = (a, b) => JSON.stringify(stable(a)) === JSON.stringify(stable(b)) +const rows = value => [...value].sort((a, b) => id(a.id).localeCompare(id(b.id))) + +export function carrierExceptionShapeErrors(detail) { + const errors = [] + if (!Array.isArray(detail?.nodes) || !Array.isArray(detail?.exceptions) || !Array.isArray(detail?.histories)) return ['missing detail arrays'] + const identities = new Set() + for (const fact of detail.exceptions) { + const identity = `${fact.providerCode}|${fact.providerEventId}` + if (!validLongId(fact.id) || !CARRIER_EXCEPTION_CODES.includes(fact.exceptionCode) + || typeof fact.active !== 'boolean' || !fact.providerCode || !fact.providerEventId + || !fact.providerStatus || !fact.observedAt || !fact.createdAt + || !/^[a-f0-9]{64}$/.test(fact.rawPayloadHash || '')) errors.push(`invalid exception ${id(fact.id)}`) + for (const [key, max] of [['locationCode', 32], ['locationName', 128], ['sourceReference', 500]]) { + if (fact[key] != null && (typeof fact[key] !== 'string' || fact[key].length > max)) errors.push(`invalid optional ${key}`) + } + if (identities.has(identity)) errors.push(`duplicate provider event ${identity}`) + identities.add(identity) + } + if (detail.nodes.some(node => CARRIER_EXCEPTION_CODES.includes(node.standardNode))) errors.push('exception stored as normal node') + return errors +} + +// A GET is evidence only. Supplier writes/replays must be orchestrated separately +// through Integration + the real scheduler; no synthetic platform write endpoint. +export async function captureCarrierExceptionEvidence(context, resource = context.createdResources?.phase3CarrierScheduleQuery) { + if (!ownsCarrierQuery(resource, context.marker)) throw new Error('requires current-marker owned carrier query') + const response = await request(context, 'GET', `${BASE}/${encodeURIComponent(resource.queryId)}`) + const detail = context.apiSuccess(MODULE, 'C66读取原船任务正常节点和独立异常事实', response, '', OPTIONS) + if (!detail || id(detail.query?.query?.id) !== id(resource.queryId)) throw new Error('carrier detail identity mismatch') + const errors = carrierExceptionShapeErrors(detail) + context.assert(MODULE, 'C66异常编码来源地点摘要完整且不混入正常节点', response, errors.length === 0, errors.join('; '), OPTIONS) + if (errors.length) throw new Error('invalid carrier exception facts') + return { marker: context.marker, queryId: id(resource.queryId), capturedAt: new Date().toISOString(), + requestId: response.requestId, traceId: response.traceId, detail } +} + +export function carrierExceptionTransitionErrors(before, after, { kind, expected }) { + const errors = [...carrierExceptionShapeErrors(before?.detail), ...carrierExceptionShapeErrors(after?.detail)] + if (!before?.marker || before.marker !== after?.marker || !before.queryId || before.queryId !== after.queryId + || id(before.detail?.query?.query?.id) !== before.queryId || id(after.detail?.query?.query?.id) !== before.queryId) errors.push('checkpoint identity mismatch') + if (errors.length) return errors + const a = before.detail, b = after.detail + if (!a.nodes.some(node => node.actualAt) || !a.nodes.some(node => node.estimatedAt)) errors.push('requires actual and estimated normal baseline') + if (!equal(rows(a.nodes), rows(b.nodes))) errors.push('normal actual/estimated facts changed') + for (const key of ['id', 'customerId', 'oceanBookingId', 'containerBookingId', 'latestStandardNode', 'latestNodeObservedAt', 'stoppedAt', 'stopReason']) { + if (!equal(a.query.query[key], b.query.query[key])) errors.push(`normal query scope/progress changed: ${key}`) + } + const histories = detail => rows(detail.histories.filter(item => item.action === 'EXCEPTION_OBSERVED')) + if (kind === 'REPLAY' || kind === 'CONFLICT') { + const original = expected && a.exceptions.find(item => item.providerCode === expected.providerCode && item.providerEventId === expected.providerEventId) + if (!original) errors.push('original event absent before replay/conflict') + if (!/^[a-f0-9]{64}$/.test(expected?.rawPayloadHash || '') + || (original && (kind === 'REPLAY' ? original.rawPayloadHash !== expected.rawPayloadHash : original.rawPayloadHash === expected.rawPayloadHash))) errors.push('replay/conflict payload hash does not match scenario') + if (!equal(rows(a.exceptions), rows(b.exceptions)) || !equal(histories(a), histories(b))) errors.push('replay/conflict changed exception facts/history') + } else if (kind === 'APPEND' || kind === 'RELEASE') { + if (!expected || !CARRIER_EXCEPTION_CODES.includes(expected.exceptionCode) || expected.active !== (kind === 'APPEND')) errors.push('invalid expected supplier fact') + const added = b.exceptions.filter(item => !a.exceptions.some(old => id(old.id) === id(item.id))) + if (added.length !== 1 || !expected || !Object.entries(expected).every(([key, value]) => equal(added[0]?.[key], value))) errors.push('expected independent fact not appended exactly once') + if (!a.exceptions.every(old => b.exceptions.some(item => equal(old, item)))) errors.push('original exception overwritten/deleted') + if (kind === 'RELEASE' && !a.exceptions.some(item => item.providerCode === expected?.providerCode && item.exceptionCode === expected?.exceptionCode && item.active === true)) errors.push('release lacks original active fact') + const oldHistory = histories(a), newHistory = histories(b) + if (newHistory.length !== oldHistory.length + 1 || !oldHistory.every(old => newHistory.some(item => equal(old, item)))) errors.push('exception history not append-only') + } else errors.push('unsupported transition') + return errors +} + +export async function verifyCarrierExceptionTransition(context, before, transition) { + const after = await captureCarrierExceptionEvidence(context) + const errors = carrierExceptionTransitionErrors(before, after, transition) + context.assert(MODULE, `C66 ${transition.kind}异常独立保存且正常实际预计节点零漂移`, null, + errors.length === 0, errors.join('; '), OPTIONS) + context.createdResources.carrierExceptionTransitions ??= [] + // Zero drift does not prove that an upstream replay/conflict was actually sent. + context.createdResources.carrierExceptionTransitions.push({ before, after, transition, errors, + supplierDeliveryProofRequired: true, completionClaim: false }) + return after +} function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } @@ -86,6 +171,10 @@ export async function run(context) { return } + const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${fixture.oceanBookingId}`) + const booking = context.apiSuccess(MODULE, '读取船任务所属订舱权威客户范围', bookingResponse, '', OPTIONS) + if (!booking?.oceanBooking || id(booking.oceanBooking.id) !== fixture.oceanBookingId) return + fixture.customerId = booking.oceanBooking.customerId == null ? '0' : id(booking.oceanBooking.customerId) const selector = new URLSearchParams({ customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20', @@ -131,4 +220,8 @@ export async function run(context) { carrierCode: task?.carrierCode, initialStatus: task?.status, version: Number(data(detailResponse)?.query?.query?.version), } + context.createdResources.carrierExceptionBaseline = await captureCarrierExceptionEvidence(context) + context.addCoverageGap(MODULE, 'carrier-exception-provider-lifecycle', 'C66供应商异常新增解除及真实重放冲突', + '正式平台API仅支持正常manual-nodes/manual-results;异常来自Integration query结果经真实scheduler写入。需总控受控供应商投递APPEND/REPLAY/CONFLICT/RELEASE并保存执行证据后调用只读checkpoint核验,空数组或重复GET不证明幂等。', + 'external-third-party') } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index b160be16c..6ce2059bf 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -1,9 +1,26 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { bookingPayload } from './phase3-order-splitting.mjs' const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8') +test('fresh completed child runs retain distinct bill numbers despite identical long marker suffixes', () => { + const sourceOrder = { tenantId: '12', sourceOrderId: '34', channelCode: 'MATSON_CLX', warehouseId: '56' } + const agent = { id: '78', name: 'agent' }, carrier = { id: '90', name: 'carrier', carrierCode: 'MATSON' } + const markers = ['ADMIN-API-20261002030454498-COMPLETED-F40-CHILD', + 'ADMIN-API-20261002034957394-COMPLETED-F40-CHILD'] + const draft = (marker, sequence = 1, order = sourceOrder) => bookingPayload(marker, sequence, order, agent, carrier) + assert.equal(`${markers[0]}-1`.replace(/[^A-Za-z0-9]/g, '').slice(-18), + `${markers[1]}-1`.replace(/[^A-Za-z0-9]/g, '').slice(-18)) + assert.notEqual(draft(markers[0]).billOfLadingNo, draft(markers[1]).billOfLadingNo) + assert.equal(draft(markers[0]).billOfLadingNo, draft(markers[0]).billOfLadingNo) + assert.notEqual(draft(markers[0]).billOfLadingNo, draft(markers[0], 2).billOfLadingNo) + assert.notEqual(draft(markers[0]).billOfLadingNo, + draft(markers[0], 1, { ...sourceOrder, sourceOrderId: '35' }).billOfLadingNo) + assert.match(draft(markers[0]).billOfLadingNo, /^BL-[a-f0-9]{18}$/) +}) + test('each remainder uses a simulation linked to a released booking requirement', () => { assert.match(source, /POST', CONTAINERS/) assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 02ede02a6..efb2d1710 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,4 +1,5 @@ import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' +import { createHash } from 'node:crypto' const MODULE = 'phase3-order-splitting' const SCENARIO = 'source-loaded-single-remainder-chain' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -47,6 +48,8 @@ function sameBookingTransport(left, right) { } async function request(context, method, path, body, headers = {}, token = undefined) { + // Shared reads retry transport failures; business rejections remain final evidence. + if (method === 'GET') return context.request(method, path, body, token ?? context.token, headers) try { const response = await fetch(`${context.options.gateway}${path}`, { method, @@ -165,8 +168,9 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) { && activeMatches && quantityConserved(tree) } -function bookingPayload(marker, sequence, source, agent, carrier) { - const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) +export function bookingPayload(marker, sequence, source, agent, carrier) { + const suffix = createHash('sha256').update(JSON.stringify([marker, sequence, id(source.sourceOrderId)])) + .digest('hex').slice(0, 18) return oceanBookingDraft({ marker }, suffix, { customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 246ce07a0..a57f4ea97 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -131,6 +131,8 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) assert.match(entry, /runPhase4ProducerVariant\('FBA-SECOND', 'FBA', 'STANDARD', true\)/) assert.match(entry, /runPhase4ProducerVariant\('FBA-OPERATIONS', 'FBA', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-ROUTE', 'FBA', 'STANDARD', true\)/) + assert.match(entry, /routeFbaReady\?\.shippingReady === true/) assert.match(entry, /secondStandardFbaReady\?\.shippingReady === true/) assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) assert.match(entry, /const hasSecondaryProducer =/) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs b/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs index 587f1e20e..3646b5b19 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs @@ -1,6 +1,16 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { customsRetentionDigest, verifyTaxCustomsAfterShipEdit } from './phase3-port-schedules.mjs' +import { customsRetentionDigest, verifyTaxCustomsAfterShipEdit, portScheduleCustomerScope } from './phase3-port-schedules.mjs' + +test('public booking null scope becomes platform zero; positive IDs retain exact precision', () => { + assert.equal(portScheduleCustomerScope(null), '0') + assert.equal(portScheduleCustomerScope(undefined), '0') + assert.equal(portScheduleCustomerScope('2105227486329671682'), '2105227486329671682') + assert.equal(portScheduleCustomerScope(123), '123') + for (const invalid of ['', '0', 0, '-1', 'customer']) { + assert.throws(() => portScheduleCustomerScope(invalid), /null or a positive ID/) + } +}) test('customs retention digest includes documents, receipt versions, confirmations and completion facts', () => { const before = { status: 'COMPLETED', documents: [{ fileId: '1', tenantId: 'A', version: 2 }], diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index a7ef61927..fe528ef47 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -131,6 +131,13 @@ function oceanDraft(context, suffix, master) { } } +export function portScheduleCustomerScope(bookingCustomerId) { + if (bookingCustomerId == null) return '0' + const value = String(bookingCustomerId) + if (!/^[1-9][0-9]*$/.test(value)) throw new Error('ocean booking customer scope must be null or a positive ID') + return value +} + async function ensureOceanBooking(context, suffix, master, preferredOceanBookingId = null) { const existingId = id(preferredOceanBookingId || context.createdResources?.oceanBooking?.id) if (existingId) { @@ -139,7 +146,7 @@ async function ensureOceanBooking(context, suffix, master, preferredOceanBooking `oceanBookingId=${existingId}`, OPTIONS) return detail?.oceanBooking ? { id: existingId, - customerId: id(detail.oceanBooking.customerId), + customerId: portScheduleCustomerScope(detail.oceanBooking.customerId), allocatedContainerBookingIds: (detail.containers || []) .map((item) => id(item?.containerBookingId)).filter(Boolean), } : null @@ -152,7 +159,7 @@ async function ensureOceanBooking(context, suffix, master, preferredOceanBooking Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId), `oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS) return oceanBookingId ? { - id: oceanBookingId, customerId: id(detail.oceanBooking.customerId), + id: oceanBookingId, customerId: portScheduleCustomerScope(detail.oceanBooking.customerId), allocatedContainerBookingIds: (detail.containers || []) .map((item) => id(item?.containerBookingId)).filter(Boolean), } : null @@ -191,6 +198,12 @@ export async function run(context) { Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS) + const publicResponse = await request(context, 'GET', `${BASE}?customerId=0&page=1&size=5`) + const publicPage = context.apiSuccess(MODULE, '平台公共船期支持customerId=0查询', publicResponse, '', OPTIONS) + context.assert(MODULE, '平台公共船期查询不混入其他客户', publicResponse, + Array.isArray(publicPage?.records) && publicPage.records.every(row => id(row?.schedule?.customerId) === '0'), + `records=${publicPage?.records?.length ?? 'missing'}`, OPTIONS) + const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const flow = context.createdResources?.phase3Loading @@ -206,7 +219,8 @@ export async function run(context) { OPTIONS) return } - const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker } + const scheduleCustomerId = ocean.customerId + const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, scheduleCustomerId), reason: context.marker } const createKey = `${context.marker}-port-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS) @@ -245,7 +259,7 @@ export async function run(context) { } const initialVersion = Number(created.portSchedule.schedule.version) - const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true) + const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, scheduleCustomerId, true) expiredData.sourceRef = payload.data.sourceRef // 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。 expiredData.vesselName = `甩柜改配船-${suffix}` @@ -305,7 +319,7 @@ export async function run(context) { `inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS) } - const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false) + const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, scheduleCustomerId, false) currentData.sourceRef = payload.data.sourceRef currentData.sourceType = 'MANUAL' currentData.sourceName = `平台人工兜底-${suffix}` diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index ae66774fe..f66d66301 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' +import { verifyDiscardApproval } from './phase4-overseas-client-services.mjs' import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8') @@ -14,7 +15,7 @@ test('client services module uses only stable merchant self-service paths', () = '/api/merchant/v1/last-leg/cargo-discards', 'inventory-candidates', ]) assert.ok(source.includes(path), `missing ${path}`) - for (const forbidden of ['/api/pda/', '/api/platform/v1/last-leg/dispatches', '/outbound', '/delivery', '/dispatch', '/depart']) { + for (const forbidden of ['/api/platform/v1/last-leg/dispatches', '/outbound', '/delivery', '/dispatch', '/depart']) { assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) } }) @@ -62,7 +63,7 @@ test('module records marker-owned resources without forbidden fulfillment cleanu assert.match(source, /relabelNo = relabelNo/) assert.match(source, /transferNo = transferNo/) assert.match(source, /discardNo = discardNo/) - assert.match(source, /no outbound, dispatch, PDA or delivery action is invoked/) + assert.match(source, /no outbound, dispatch or delivery action is invoked/) }) test('focused client services keeps the marker-owned merchant fixture when reusable credentials exist', () => { @@ -75,3 +76,42 @@ test('focused client services keeps the marker-owned merchant fixture when reusa const clientServices = parsed.modules.indexOf('phase4-overseas-client-services') assert.ok(fixture >= 0 && fixture < clientServices) }) + +test('discard follows formal approval deduction and keeps physical ledger evidence pending', () => { + assert.match(source, /正式废弃合同要求审批事务扣减一箱/) + assert.match(source, /废弃拒绝释放原箱且全部库存数量流水不变/) + assert.match(source, /APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING/) + assert.doesNotMatch(source, /INSERT INTO|UPDATE ll_|fleet-trip|\/api\/pda\//) +}) + +for (const fault of ['', 'pending deducts', 'reject does not release', 'approve does not deduct', 'create conflict mutates']) { + test(`discard approval stock boundaries: ${fault || 'formal approval contract'}`, async () => { + let state = 'PENDING_REVIEW', approved = false, conflictSeen = false + const baseline = { balances: [{customerId:'123',overseasWarehouseId:'42',availablePackages:'4'}], ledger:[], + source:{orderId:'9',overseasWarehouseId:'42',inboundCode:'IP',availablePackages:'4'},detail:null } + const proof = {sourceOrderId:'9',customerId:'123',warehouseId:'42',inboundCode:'IP',snapshots:{before:baseline}} + const calls=[] + const context={marker:'fixture',token:'admin',addStep(){}, + assert(_m,name,_r,passed){ assert.ok(passed,name); if(name==='重复批准不会提前扣库存或新增历史') throw new Error('CHECKPOINT_PASSED') }, + apiSuccess(_m,_n,r){ assert.equal(r.body.code,'200');return r.body.data }, + async request(method,raw,body){ + calls.push({method,raw,body}) + const path=new URL(raw,'https://offline.invalid').pathname + let data + if(method==='POST' && path.endsWith('/review')) {state=body.decision==='REJECT'?'REJECTED':'APPROVED';data={}} + else if(method==='POST' && path.endsWith('/cargo-discards')) { + if(body.remark.endsWith('-DIFFERENT')) {conflictSeen=true;return {http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}} + approved=true;state='PENDING_REVIEW';data={summary:{discardNo:'NEW'}} + } else if(path.endsWith('/balances')) { + const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault!=='approve does not deduct') || (conflictSeen && fault==='create conflict mutates') + data={total:'1',records:[{...baseline.balances[0],availablePackages:changed?'3':'4'}]} + } else if(path.endsWith('/ledger')) data={total:'0',records:[]} + else if(path.endsWith('/inventory-candidates')) data=[{...baseline.source,availablePackages:state==='REJECTED' && fault!=='reject does not release'?'4':'3'}] + else data={summary:{discardNo:approved?'NEW':'OLD',status:state}} + return {http:200,body:{code:'200',data}} + }} + await assert.rejects(verifyDiscardApproval(context,'merchant',{idempotencyKey:'key',remark:'fixture'},'OLD',proof), + fault ? /仅锁定|释放原箱|审批事务扣减|异载荷拒绝后/ : /CHECKPOINT_PASSED/) + assert.ok(!calls.some(call=>call.raw.includes('/pda/'))) + }) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 6a8572cce..36868b0ec 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -229,7 +229,7 @@ async function verifyTransfer(context, token, orders) { return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo) } -async function verifyDiscard(context, token, markerOrders) { +export async function verifyDiscard(context, token, markerOrders) { const response = await context.request('GET', `${DISCARD}/inventory-candidates`, undefined, token, {}) const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS) const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id))) @@ -245,6 +245,14 @@ async function verifyDiscard(context, token, markerOrders) { items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }], idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker, } + const proof = context.createdResources.phase4OverseasClientServices.discardApproval = { + sourceOrderId: id(candidate.orderId), customerId: id(context.context.merchantQualificationFixture.tenantId), + warehouseId: candidate.overseasWarehouseId, inboundCode: candidate.inboundCode, + skuCode: sku.skuCode, snapshots: {}, status: 'RUNNING', + contract: 'Current stage-five and service AGENTS require approval-transaction deduction. Review has no idempotencyKey. An approval that only enables PDA must fail this contract.', + } + const before = await discardSnapshot(context, token, proof) + proof.snapshots.before = before const created = await merchant(context, token, 'POST', '客户创建货物废弃申请', DISCARD, body) const discardNo = id(created.data?.summary?.discardNo) context.assert(MODULE, '废弃申请只锁定库存并进入合法待审核状态', created.response, @@ -267,14 +275,97 @@ async function verifyDiscard(context, token, markerOrders) { && Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'), `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.discardNo = discardNo + try { await verifyDiscardApproval(context, token, body, discardNo, proof) } + catch (error) { proof.status = 'FAILED'; proof.failure = error.message; throw error } return true } + +function stable(value) { + if (Array.isArray(value)) return JSON.stringify(value.map(stable).sort()) + if (value && typeof value === 'object') return JSON.stringify(Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])]))) + return value +} +function discardRequire(context, name, passed, evidence = '') { + context.assert(MODULE, name, null, Boolean(passed), evidence, OPTIONS) + if (!passed) throw new Error(name) +} +async function discardCommand(context, token, method, name, path, body) { + const result = await merchant(context, token, method, name, path, body) + if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) + return result.data +} +async function discardSnapshot(context, token, proof, discardNo) { + const all = async (kind) => { + const rows = [] + let total + for (let page = 1; page <= 100; page++) { + const data = await discardCommand(context, context.token, 'GET', `废弃核对完整${kind}`, + query(`/api/platform/v1/last-leg/inventory/${kind}`, { customerId: proof.customerId, + overseasWarehouseId: proof.warehouseId, page, size: 200 })) + discardRequire(context, '废弃库存分页作用域与总数稳定', Array.isArray(data?.records) + && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 + && (total === undefined || total === Number(data.total)) + && data.records.every(row => id(row.customerId) === proof.customerId && row.overseasWarehouseId === proof.warehouseId)) + total = Number(data.total); rows.push(...data.records) + if (rows.length === total) return rows + discardRequire(context, '废弃库存分页不能截断', rows.length < total && data.records.length > 0) + } + throw new Error('废弃库存分页超过上限') + } + const balances = await all('balances'), ledger = await all('ledger') + const candidates = await discardCommand(context, token, 'GET', '废弃回查本轮客户真实候选', `${DISCARD}/inventory-candidates`) + const source = candidates.find(row => id(row.orderId) === proof.sourceOrderId) + const detail = discardNo ? await discardCommand(context, token, 'GET', '废弃回查申请真实状态', `${DISCARD}/${encodeURIComponent(discardNo)}`) : null + return { balances, ledger, source: source || null, detail } +} +export async function verifyDiscardApproval(context, token, body, discardNo, proof) { + const require = (name, passed) => discardRequire(context, name, passed, `discardNo=${discardNo}`) + const sameStock = (a,b) => stable(a.balances) === stable(b.balances) && stable(a.ledger) === stable(b.ledger) + const base = proof.snapshots.before + require('废弃来源属于本轮客户及实际收货仓', base.source?.overseasWarehouseId === proof.warehouseId + && base.source?.inboundCode === proof.inboundCode && Number(base.source.availablePackages) >= 1) + let pending = await discardSnapshot(context, token, proof, discardNo) + proof.snapshots.pending = pending + require('申请仅锁定一箱且余额流水未扣减', sameStock(base,pending) + && Number(pending.source?.availablePackages || 0) === Number(base.source.availablePackages) - 1) + const conflictBody = { ...body, remark: `${body.remark}-DIFFERENT` } + const response = await context.request('POST', DISCARD, conflictBody, token, {}) + context.addStep(MODULE, '废弃创建同键异载荷拒绝', response, conflict(response), trace(response), OPTIONS) + require('废弃创建异载荷有明确冲突诊断', conflict(response) && nonBlank(response.requestId) && nonBlank(response.traceId)) + require('废弃创建异载荷拒绝后事实不变', stable(await discardSnapshot(context,token,proof,discardNo)) === stable(pending)) + const root = '/api/platform/v1/last-leg/cargo-discards' + const review = (number,decision) => discardCommand(context, context.token, 'POST', `废弃审批${decision}`, + query(`${root}/${encodeURIComponent(number)}/review`, { customerId: proof.customerId }), { decision, remark: context.marker }) + await review(discardNo,'REJECT') + const rejected = await discardSnapshot(context,token,proof,discardNo); proof.snapshots.rejected = rejected + require('废弃拒绝释放原箱且全部库存数量流水不变', rejected.detail?.summary?.status === 'REJECTED' + && sameStock(base,rejected) && stable(base.source) === stable(rejected.source)) + await review(discardNo,'REJECT') + require('废弃重复拒绝不重复释放', stable(await discardSnapshot(context,token,proof,discardNo)) === stable(rejected)) + const approvedBody = { ...body, idempotencyKey: `${body.idempotencyKey}-APPROVE` } + const created = await discardCommand(context,token,'POST','创建独立待批准废弃申请',DISCARD,approvedBody) + const approvedNo = id(created?.summary?.discardNo); require('批准对照使用本轮新申请', nonBlank(approvedNo) && approvedNo !== discardNo) + proof.approvedNo = approvedNo + await review(approvedNo,'APPROVE') + const approved = await discardSnapshot(context,token,proof,approvedNo); proof.snapshots.approved = approved + const available = snapshot => snapshot.balances.reduce((sum,row)=>sum+Number(row.availablePackages),0) + require('正式废弃合同要求审批事务扣减一箱', approved.detail?.summary?.status === 'APPROVED' && available(base)-available(approved) === 1) + await review(approvedNo,'APPROVE') + require('重复批准不会提前扣库存或新增历史', stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved)) + const reverse = await context.request('POST',query(`${root}/${encodeURIComponent(approvedNo)}/review`,{customerId:proof.customerId}),{decision:'REJECT',remark:context.marker},context.token,{}) + require('批准后反向审核拒绝且事实不变', conflict(reverse) && nonBlank(reverse.requestId) && nonBlank(reverse.traceId) + && stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved)) + proof.status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING' + proof.remainingEvidence = 'll_dropship_carton, ll_dropship_sku_inventory, ll_dropship_carton_ledger require independent read-only evidence; public ledger is not carton ledger' + +} + export async function run(context) { context.createdResources.phase4OverseasClientServices = { marker: context.marker, relabelNo: null, transferNo: null, discardNo: null, finalStatuses: { relabel: null, transfer: null, discard: null }, - cleanup: 'Relabel/discard remain pending; transfers are reviewed with separate incoming approval/rejection fixtures; no outbound, dispatch, PDA or delivery action is invoked', + cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval-transaction deduction checked; no outbound, dispatch or delivery action is invoked', } const token = await loginMerchant(context) if (!token) return @@ -300,6 +391,6 @@ export async function run(context) { context.createdResources.phase4OverseasClientServices.finalStatuses = { relabel: relabelDone ? 'PENDING_REVIEW' : null, transfer: transferDone ? 'APPROVED' : null, - discard: discardDone ? 'PENDING_REVIEW' : null, + discard: discardDone ? 'APPROVED' : null, } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 5c7a348f2..09d37da67 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -2,7 +2,8 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { dispatchBusinessSnapshot } from './phase4-overseas-dispatch.mjs' +import { dispatchBusinessSnapshot, dispatchRouteSnapshot, dispatchRouteMatches, + selectDispatchRouteFixture, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) const source = await readFile(fileURLToPath(moduleUrl), 'utf8') @@ -29,13 +30,117 @@ test('module uses marker-owned completed PDA pallets and never borrows historica }) test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => { - assert.match(source, /const candidatesByDestination = new Map\(\)/) - assert.match(source, /destinationWarehouseCode \|\| value\?\.fbaWarehouseCode/) + assert.match(source, /const candidatesByDestination = new Map\(selectedCandidates.map/) + assert.match(source, /row\?\.fbaWarehouseCode \|\| row\?\.destinationWarehouseCode/) assert.match(source, /selectedCandidates\.length >= 2/) - assert.match(source, /planPalletIds, route: `\$\{context\.marker\}-ROUTE`/) + assert.match(source, /planPalletIds, fbaAppointmentNo: null/) assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/) }) +function routeCandidates() { + return [['20', '120', 'ABE4', 2], ['30', '130', 'ABE8', 3], ['21', '121', 'ABE4', 4]].map(([planPalletId, warehousePalletId, fbaWarehouseCode, packageCount]) => ({ + planId: '10', planPalletId, warehousePalletId, fbaWarehouseCode, destinationWarehouseCode: fbaWarehouseCode, + overseasWarehouseId: '900', customerId: '100', inboundCode: `IN-${planPalletId}`, sourceStage: 'RECEIVED', destinationType: 'FBA', packageCount, + })) +} +function responseFor(candidates) { + return { id: '1', dispatchNo: 'DS-TEST', overseasWarehouseId: '900', status: 'PENDING_DEPARTURE', + route: [...new Set(candidates.map(row => row.fbaWarehouseCode))].join(','), + items: candidates.map(row => ({ ...row, id: '8' + row.planPalletId })), + itemCount: candidates.length, palletCount: candidates.length, + packageCount: candidates.reduce((sum, row) => sum + row.packageCount, 0), pickedPackageCount: 0, shippedPackageCount: 0 } +} + +test('C52 fixture needs two destinations and two distinct physical pallets at one destination', () => { + const rows = routeCandidates() + assert.equal(selectDispatchRouteFixture(rows.slice(0, 2)), null) + assert.equal(selectDispatchRouteFixture([rows[0], rows[2]]), null) + assert.equal(selectDispatchRouteFixture([rows[0], rows[1], { ...rows[2], warehousePalletId: rows[0].warehousePalletId }]), null) + const result = selectDispatchRouteFixture(rows) + assert.deepEqual(result.selected.map(row => row.planPalletId), ['20', '30']) + assert.equal(result.duplicate.planPalletId, '21') + assert.match(source, /generatedPalletsByInbound/) + assert.match(source, /id\(value\?\.customerId\) === id\(fixture.customerId\)/) + assert.match(source, /value\?\.destinationType === 'FBA'/) +}) + +test('C52 independently derives route from actual rows and rejects fabricated destinations, sources, totals and duplicate route codes', () => { + const rows = routeCandidates(), value = responseFor(rows) + assert.equal(dispatchRouteMatches(value, rows), true) + for (const route of ['ABE4', 'ABE4,ABE8,ABE9', 'ABE4,ABE8,ABE4', 'ABE4,ABE8,', '']) { + assert.equal(dispatchRouteMatches({ ...value, route }, rows), false) + } + for (const patch of [{ itemCount: 9 }, { packageCount: 10 }, { overseasWarehouseId: '901' }, { status: 'PICKING' }]) { + assert.equal(dispatchRouteMatches({ ...value, ...patch }, rows), false) + } + for (const patch of [{ inboundCode: 'FOREIGN' }, { warehousePalletId: '999' }, { fbaWarehouseCode: 'ABE9' }, { planPalletId: '999' }]) { + assert.equal(dispatchRouteMatches({ ...value, items: [{ ...value.items[0], ...patch }, ...value.items.slice(1)] }, rows), false) + } + assert.notDeepEqual(dispatchRouteSnapshot(value), dispatchRouteSnapshot({ ...value, items: [{ ...value.items[0], id: '999' }, ...value.items.slice(1)] })) + assert.notEqual(dispatchBusinessSnapshot(value), dispatchBusinessSnapshot({ ...value, route: 'ABE9' })) +}) + +function lifecycleHarness(fault = '') { + const rows = routeCandidates(), selected = rows.slice(0, 2) + let state = responseFor(selected), nextItemId = 900, preArrangeCount = 0 + const calls = [], checks = [] + const base = '/api/platform/v1/last-leg/dispatches' + const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'REQ', traceId: 'TRACE' }) + const error = code => ({ http: 200, body: { code }, requestId: 'REQ', traceId: 'TRACE' }) + const context = { marker: 'CURRENT', createdResources: { phase4OverseasDispatch: {} }, + apiSuccess: (_module, _name, response) => response.body.data, + assert: (_module, name, _response, passed) => checks.push({ name, passed }), + request: async (method, path, body) => { + calls.push({ method, path, body }) + if (path.includes('/candidates')) return ok(rows.filter(row => !state.items.some(item => item.planPalletId === row.planPalletId))) + if (path.endsWith('/draft-locks/lock')) return ok(null) + if (method === 'GET') return ok(fault === 'wrong-route' ? { ...state, route: 'ABE9' } : state) + if (method === 'DELETE') { + const itemId = path.split('/').at(-1), remaining = state.items.filter(row => row.id !== itemId) + if (remaining.length === state.items.length) { + if (fault === 'rejected-delete-mutates') state.route = 'ABE9' + return error('A0404') + } + state = { ...responseFor(remaining), items: remaining } + return ok(state) + } + if (path === `${base}/pre-arrange`) { + if (new Set(body.planPalletIds).size !== body.planPalletIds.length) return error('A0400') + if (body.draftToken !== 'DRAFT') return error('A0409') + preArrangeCount++ + const picked = [...body.planPalletIds].sort().map(palletId => rows.find(row => row.planPalletId === palletId)) + const items = picked.map(row => state.items.find(item => item.planPalletId === row.planPalletId) || { ...row, id: String(nextItemId++) }) + state = { ...responseFor(picked), items } + if (fault === 'replay-id-drift' && preArrangeCount === 2) state.items[0].id = String(nextItemId++) + return ok(state) + } + throw new Error(`unexpected request ${method} ${path}`) + } } + return { context, calls, checks, options: { dispatchNo: 'DS-TEST', candidates: selected, duplicate: rows[2], draftToken: 'DRAFT', draftContainerId: '50' } } +} + +test('C52 HTTP orchestration adds duplicate destination, removes last destination, restores and freezes exact replay/rejection facts', async () => { + const h = lifecycleHarness() + await verifyDispatchRouteLifecycle(h.context, h.options) + assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED') + assert.ok(h.checks.length >= 20 && h.checks.every(row => row.passed)) + for (const label of ['同目的地第二托增加箱托数但路线稳定去重', '最后一托移除后路线删除该目的地', '重新加入后恢复原目的地集合', '重复托盘ID拒绝A0400', '其他草稿抢占原排车拒绝A0409']) { + assert.ok(h.checks.some(row => row.name.includes(label))) + } + assert.ok(h.calls.filter(row => row.path.endsWith('/pre-arrange')).every(row => !Object.hasOwn(row.body, 'route'))) + assert.equal(h.calls.filter(row => row.method === 'DELETE').length, 3) + assert.deepEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages.at(-1).snapshot.items.map(row => row.planPalletId), ['20', '30']) +}) + +test('C52 stops on wrong route, replay row identity drift or side effects after rejected removal', async () => { + for (const fault of ['wrong-route', 'replay-id-drift', 'rejected-delete-mutates']) { + const h = lifecycleHarness(fault) + await assert.rejects(verifyDispatchRouteLifecycle(h.context, h.options), /C52/) + assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'UNPROVEN') + assert.ok(h.checks.some(row => !row.passed)) + } +}) + test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { assert.match(source, /相同草稿快照重复保存/) assert.match(source, /同草稿重复锁定托盘幂等成功/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index d60708ea8..e0e06d9fa 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -11,10 +11,11 @@ function codeOf(response) { return response?.body?.code == null ? '' : String(re function success(response) { return response?.http === 200 && codeOf(response) === '200' } export function dispatchBusinessSnapshot(value) { return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, - packageCount: value?.packageCount, palletCount: value?.palletCount, + route: value?.route, packageCount: value?.packageCount, palletCount: value?.palletCount, items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId), warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage, - packageCount: item.packageCount, inboundCode: item.inboundCode })) + packageCount: item.packageCount, inboundCode: item.inboundCode, + destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode })) .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), }) } @@ -45,11 +46,150 @@ function blocker(context, name, evidence, response = null) { function markerFixture(context) { const fixture = context.createdResources?.phase4OverseasPda if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null - const palletIds = new Set((fixture.palletIds || []).map(id).filter(Boolean)) + const palletIds = new Set([...(fixture.palletIds || []), + ...Object.values(fixture.generatedPalletsByInbound || {}).flat().filter(row => + id(row.warehouseId) === id(fixture.overseasWarehouseId)).map(row => row.palletId)].map(id).filter(Boolean)) if (!fixture.overseasWarehouseId || palletIds.size === 0) return null return { ...fixture, palletIds } } +const destination = row => String(row?.fbaWarehouseCode || row?.destinationWarehouseCode || '').trim() +const sorted = values => [...values].sort() +const same = (left, right) => JSON.stringify(left) === JSON.stringify(right) +// Item IDs are included here: editable pre-arrange retains existing rows, and rejected +// commands must preserve the exact assigned facts, not merely their counts. +export function dispatchRouteSnapshot(value) { + return { id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, + route: value?.route ?? '', warehouseId: id(value?.overseasWarehouseId), + itemCount: value?.itemCount, palletCount: value?.palletCount, packageCount: value?.packageCount, + pickedPackageCount: value?.pickedPackageCount, shippedPackageCount: value?.shippedPackageCount, + items: (value?.items || []).map(row => ({ id: id(row.id), planPalletId: id(row.planPalletId), + warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo, + warehouseId: id(row.overseasWarehouseId), inboundCode: row.inboundCode, + orderNo: row.orderNo, orderRefs: row.orderRefs, sourceStage: row.sourceStage, + destinationWarehouseCode: row.destinationWarehouseCode, fbaWarehouseCode: row.fbaWarehouseCode, + packageCount: row.packageCount })).sort((a, b) => a.planPalletId.localeCompare(b.planPalletId)) } +} + +export function dispatchRouteMatches(value, expectedCandidates) { + if (!Array.isArray(value?.items) || !Array.isArray(expectedCandidates)) return false + const items = value.items, route = String(value.route || '').trim() + const routeParts = route ? route.split(',').map(part => part.trim()) : [] + const actualDestinations = sorted(new Set(items.map(destination))) + return value.status === 'PENDING_DEPARTURE' + && Number(value.itemCount) === items.length && Number(value.palletCount) === items.length + && Number(value.packageCount) === items.reduce((total, row) => total + Number(row.packageCount), 0) + && items.length === expectedCandidates.length + && new Set(items.map(row => id(row.planPalletId))).size === items.length + && new Set(items.map(row => id(row.warehousePalletId))).size === items.length + && items.every(row => /^[1-9][0-9]*$/.test(id(row.id)) && destination(row) + && expectedCandidates.some(expected => id(expected.planPalletId) === id(row.planPalletId) + && id(expected.warehousePalletId) === id(row.warehousePalletId) + && id(expected.overseasWarehouseId) === id(row.overseasWarehouseId) + && id(value.overseasWarehouseId) === id(row.overseasWarehouseId) + && expected.sourceStage === row.sourceStage && expected.inboundCode === row.inboundCode + && Number(expected.packageCount) === Number(row.packageCount) && Number(row.packageCount) > 0 + && destination(expected) === destination(row))) + && routeParts.every(Boolean) && routeParts.length === new Set(routeParts).size + && same(sorted(routeParts), actualDestinations) +} + +export function selectDispatchRouteFixture(candidates) { + const unique = [...new Map(candidates.map(row => [id(row.warehousePalletId), row])).values()] + const groups = new Map() + for (const row of unique) { + const code = destination(row) + if (code) groups.set(code, [...(groups.get(code) || []), row]) + } + const repeated = [...groups.values()].find(rows => rows.length >= 2) + if (groups.size < 2 || !repeated) return null + return { selected: [...groups.values()].map(rows => rows[0]), duplicate: repeated[1] } +} + +export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate, draftToken, draftContainerId }) { + const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { + marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], + } + const detailPath = `${BASE}/${encodeURIComponent(dispatchNo)}` + let identity = null + const check = (name, response, passed, evidence) => { + context.assert(MODULE, `C52 ${name}`, response, passed, evidence, OPTIONS) + if (!passed) throw new Error(`C52 ${name}`) + } + const read = async (name, expected) => { + const current = await api(context, 'GET', `C52 ${name}回查实际分配明细`, detailPath) + const snapshot = dispatchRouteSnapshot(current.data) + const passed = success(current.response) && current.data?.dispatchNo === dispatchNo + && (!identity || snapshot.id === identity) && dispatchRouteMatches(current.data, expected) + proof.stages.push({ name, passed, snapshot, expectedPlanPalletIds: expected.map(row => id(row.planPalletId)), + requestId: current.response?.requestId, traceId: current.response?.traceId }) + check(`${name}实际明细独立推导路线及本轮来源核平`, current.response, passed, JSON.stringify(snapshot)) + identity ||= snapshot.id + return current.data + } + const lock = async row => { + const result = await api(context, 'POST', 'C52 新增本轮托盘到原草稿锁', `${BASE}/draft-locks/lock`, + { planPalletId: id(row.planPalletId), draftToken, draftContainerId }) + check('新增草稿锁成功', result.response, success(result.response), trace(result.response)) + } + const arrange = async (name, selected) => { + const body = { planPalletIds: selected.map(row => id(row.planPalletId)), draftToken } + const result = await api(context, 'POST', `C52 ${name}`, `${BASE}/pre-arrange`, body) + check(`${name}复用原排车`, result.response, success(result.response) && result.data?.dispatchNo === dispatchNo, trace(result.response)) + const current = await read(name, selected) + const replay = await api(context, 'POST', `C52 ${name}同草稿同载荷重放`, `${BASE}/pre-arrange`, body) + const after = await read(`${name}重放后`, selected) + check(`${name}重放路线及完整明细零漂移`, replay.response, success(replay.response) + && same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(replay.data)) + && same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(replay.response)) + return after + } + const rejectUnchanged = async (name, method, path, body, expectedCode, current, selected) => { + const result = await context.request(method, path, body) + // These business errors use the current gateway envelope and must retain traces. + check(name, result, result?.http === 200 && codeOf(result) === expectedCode + && Boolean(result.requestId) && Boolean(result.traceId), trace(result)) + const after = await read(`${name}后`, selected) + check(`${name}路线数量及明细零漂移`, result, + same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(result)) + } + const initial = await read('初始多目的地', candidates) + const expanded = [...candidates, duplicate] + await lock(duplicate) + const added = await arrange('新增同目的地第二托', expanded) + check('同目的地第二托增加箱托数但路线稳定去重', null, + initial.route === added.route && added.palletCount === initial.palletCount + 1, + `before=${initial.route}, after=${added.route}`) + const extraItem = added.items.find(row => id(row.planPalletId) === id(duplicate.planPalletId)) + const removeExtra = await api(context, 'DELETE', 'C52 移除同目的地第二托', `${detailPath}/items/${id(extraItem.id)}`) + const dedup = await read('移除同目的地第二托', candidates) + check('同目的地仍有托盘时路线不消失', removeExtra.response, success(removeExtra.response) && dedup.route === initial.route, trace(removeExtra.response)) + const removedCandidate = candidates.find(row => destination(row) !== destination(duplicate)) + const removedItem = dedup.items.find(row => id(row.planPalletId) === id(removedCandidate.planPalletId)) + const reducedCandidates = candidates.filter(row => row !== removedCandidate) + const removePath = `${detailPath}/items/${id(removedItem.id)}` + const removed = await api(context, 'DELETE', 'C52 移除某目的地最后一托', removePath) + check('移除最后一托请求成功', removed.response, success(removed.response), trace(removed.response)) + const reduced = await read('移除目的地最后一托', reducedCandidates) + check('最后一托移除后路线删除该目的地', null, + !reduced.route.split(',').map(part => part.trim()).includes(destination(removedCandidate)), `route=${reduced.route}`) + await rejectUnchanged('重复移除已删除明细A0404', 'DELETE', removePath, undefined, 'A0404', reduced, reducedCandidates) + await lock(removedCandidate) + const restored = await arrange('重新加入已移除目的地', candidates) + check('重新加入后恢复原目的地集合', null, + same(sorted(restored.route.split(',')), sorted(initial.route.split(','))), `route=${restored.route}`) + const body = { planPalletIds: candidates.map(row => id(row.planPalletId)), draftToken } + await rejectUnchanged('重复托盘ID拒绝A0400', 'POST', `${BASE}/pre-arrange`, + { ...body, planPalletIds: [...body.planPalletIds, body.planPalletIds[0]] }, 'A0400', restored, candidates) + await rejectUnchanged('其他草稿抢占原排车拒绝A0409', 'POST', `${BASE}/pre-arrange`, + { ...body, draftToken: `${context.marker}-C52-FOREIGN`.slice(0, 64) }, 'A0409', restored, candidates) + const freed = await api(context, 'GET', 'C52 移除重复目的地托盘后候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + check('额外托盘正式锁已释放', freed.response, success(freed.response) && Array.isArray(freed.data) + && freed.data.some(row => id(row.planPalletId) === id(duplicate.planPalletId) + && id(row.warehousePalletId) === id(duplicate.warehousePalletId)), trace(freed.response)) + proof.status = 'VERIFIED' +} + async function runRelabelDispatch(context, relabel) { const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选', query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' })) @@ -68,7 +208,7 @@ async function runRelabelDispatch(context, relabel) { const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, { planPalletIds: candidates.map((value) => id(value.warehousePalletId)), - route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null, + fbaAppointmentNo: null, fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo, }) const dispatchNo = id(arranged.data?.dispatchNo) @@ -124,14 +264,16 @@ export async function run(context) { const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId)) && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) + && id(value?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA' && id(value?.planId) && id(value?.planPalletId) && value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0) - const candidatesByDestination = new Map() - for (const value of markerCandidates) { - const destination = String(value?.destinationWarehouseCode || value?.fbaWarehouseCode || '').trim().toUpperCase() - if (destination && !candidatesByDestination.has(destination)) candidatesByDestination.set(destination, value) + const routeFixture = selectDispatchRouteFixture(markerCandidates) + if (!routeFixture) { + blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据') + return } - const selectedCandidates = [...candidatesByDestination.values()] + const selectedCandidates = routeFixture.selected + const candidatesByDestination = new Map(selectedCandidates.map(row => [destination(row), row])) const candidate = selectedCandidates[0] context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response, selectedCandidates.length >= 2, @@ -144,7 +286,7 @@ export async function run(context) { const planPalletIds = selectedCandidates.map(value => id(value.planPalletId)) // Exercise removal on a disposable dispatch, then reuse the released current-run pallet. const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, { - planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`, + planPalletIds: [planPalletId], }) const removableNo = id(removable.data?.dispatchNo) const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId) @@ -241,7 +383,7 @@ export async function run(context) { context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) const preArrangeBody = { - planPalletIds, route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, + planPalletIds, fbaAppointmentNo: null, fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, } const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) @@ -278,6 +420,9 @@ export async function run(context) { && detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId), `dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS) + await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selectedCandidates, + duplicate: routeFixture.duplicate, draftToken, draftContainerId: id(finalContainer.data.id) }) + const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken }) if (success(released.response)) { const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 5d1c559ef..8d2840a89 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -1,7 +1,16 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { run } from './phase4-overseas-master-data.mjs' +import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs' + +test('vehicle capacity evidence rejects absent, zero and guessed dimensions', () => { + const expected = { maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } + assert.equal(vehicleCapacityMatches(expected, expected), true) + assert.equal(vehicleCapacityMatches({ maxPalletSpaces: '30', maxLoadKg: '18000.00', maxVolumeCbm: '90.0' }, expected), true) + for (const key of Object.keys(expected)) for (const value of [undefined, null, '', 0, Infinity, 'unknown', 123]) { + assert.equal(vehicleCapacityMatches({ ...expected, [key]: value }, expected), false, `${key}: ${value}`) + } +}) const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8') const controllerSource = [ @@ -28,12 +37,13 @@ function vehicleWire(state) { return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) } } -function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { +function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak = false, processingMarksPaid = false } = {}) { const calls = [] const coverageGaps = [] const steps = [] const marker = 'ADMIN-API-20260821123456789' const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null } + const careRows = new Map(), careHistory = new Map(), uploadKeys = new Map() const context = { marker, calls, coverageGaps, steps, createdResources: {}, async findAvailablePhone() { return '13800138000' }, @@ -62,6 +72,37 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) assert.notEqual(method, 'DELETE') if (token === 'deny-overseas-token') return forbidden() + if (path.includes('/fleet/vehicles/')) { + const url = new URL(path, 'https://fixture.invalid'), parts = url.pathname.split('/records'), owner = parts[0].split('/').at(-1) + const suffix = parts[1], record = careRows.get(suffix.split('/')[1]) + if (record && owner !== record.vehicleId && !careLeak) return { http: 200, body: { code: 'A0404' } } + if (method === 'POST' && !suffix) { + const prior = [...careRows.values()].find(value => value.key === body.idempotencyKey && value.vehicleId === owner) + if (prior) return ok(structuredClone(prior)) + const created = { recordNo: `VR-${careRows.size + 1}`, vehicleId: owner, details: structuredClone(body.details), files: [], recordVersion: 0, key: body.idempotencyKey, + paymentStatus: ['REPAIR', 'RESCUE'].includes(body.details.recordType) ? 'UNLINKED' : null } + careRows.set(created.recordNo, created); careHistory.set(created.recordNo, [{ snapshot: structuredClone(created) }]); return ok(structuredClone(created)) + } + if (method === 'GET' && !suffix) { + const rows = [...careRows.values()].filter(value => value.vehicleId === owner && (!url.searchParams.get('recordType') || value.details.recordType === url.searchParams.get('recordType'))) + return ok({ records: structuredClone(rows), total: rows.length }) + } + assert.ok(record) + if (method === 'GET' && suffix.endsWith('/history')) return ok({ records: structuredClone(careHistory.get(record.recordNo)) }) + if (method === 'GET') return ok(structuredClone(record)) + if (method === 'PUT') { + if (Number(body.recordVersion) !== record.recordVersion) return conflict() + record.details = structuredClone(body.details); record.recordVersion++ + if (processingMarksPaid && record.details.incident?.processingStatus === 'COMPLETED') record.paymentStatus = 'PAID' + careHistory.get(record.recordNo).unshift({ reason: body.reason, snapshot: structuredClone(record) }); return ok(structuredClone(record)) + } + } + if (method === 'POST' && path === `${OVERSEAS_BASE}/vehicles` && body.plateNumber.startsWith('CARE-')) { + state.spareVehicle = { ...body, id: '930000000000000002', recordVersion: 0 }; return ok({ ...state.spareVehicle }) + } + if (method === 'PATCH' && path === `${OVERSEAS_BASE}/vehicles/930000000000000002/status`) { + state.spareVehicle.status = body.status; state.spareVehicle.recordVersion++; return ok({ ...state.spareVehicle }) + } if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) { state.optionReads += 1 if (!state.warehouse) return ok([]) @@ -194,14 +235,25 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { return handler.apply(this,args); }, + async requestMultipart(method, path, file) { + calls.push({ gateway: 'overseas', method, path }) + const url = new URL(path, 'https://fixture.invalid'), number = url.pathname.split('/records/')[1].split('/')[0] + const record = careRows.get(number), key = url.searchParams.get('idempotencyKey') + assert.equal(file.type, 'image/png') + if (uploadKeys.has(key)) return ok(structuredClone(record)) + assert.equal(Number(url.searchParams.get('recordVersion')), record.recordVersion) + record.recordVersion++; record.files.push({ fileId: '990000000000000001' }); uploadKeys.set(key, true) + return ok(structuredClone(record)) + }, async requestBinary(method, path) { calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') + if (path.includes('/fleet/vehicles/')) return { http: 200, contentType: 'image/png', bytes: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') } assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) let csv = '' if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n` else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n` - else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n` + else csv = `车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},${state.vehicle.maxPalletSpaces},${state.vehicle.maxLoadKg},${state.vehicle.maxVolumeCbm},2026-08-21\r\n` return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`), contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } }, @@ -209,7 +261,7 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { return { context, state } } -test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { +test('静态锁定统一Gateway主数据生命周期、CSV与安全边界', () => { assert.match(source, /context\.request\('POST', DOMESTIC_BASE/) assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/) assert.match(source, /context\.request\('POST'/) @@ -233,6 +285,22 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { assert.doesNotMatch(source, /Idempotency-Key/) }) +test('API dropping updated vehicle capacity fails even with a correct CSV and cleans up the actor', async () => { + const { context } = fakeContext({ softAssertions: true }) + const request = context.request.bind(context) + context.request = async (...args) => { + const result = await request(...args) + if (args[0] === 'PUT' && /\/vehicles\/\d+$/.test(args[1]) && result.body?.code === '200') { + return { ...result, body: { ...result.body, data: { ...result.body.data, maxLoadKg: 18000 } } } + } + return result + } + await run(context) + assert.equal(context.steps.find(step => step.name === '车辆编辑保持三项实际容量')?.passed, false) + assert.equal(context.steps.find(step => step.name === '导出marker车辆CSV表头与marker内容')?.passed, true) + assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') +}) + test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => { const { context, state } = fakeContext() await run(context) @@ -253,9 +321,11 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全 assert.equal(state.role.status, 'DISABLED') assert.equal(state.user.status, 'DISABLED') assert.equal(state.optionReads, 4) - assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3) + assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export?')).length, 3) + assert.equal(resource.vehicle.care.status, 'VERIFIED') + assert.equal(state.spareVehicle.status, 'INACTIVE') assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) - assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9) + assert.equal(context.calls.filter((call) => call.method === 'PUT' && !call.path.includes('/fleet/vehicles/')).length, 9) assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1) assert.equal(context.coverageGaps.length, 0) }) @@ -273,6 +343,24 @@ test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价 assert.equal(context.coverageGaps.length, 0) }) +test('运维记录跨车泄露会阻断并保留证据,仍停用隔离用测试车辆', async () => { + const { context, state } = fakeContext({ softAssertions: true, careLeak: true }) + await run(context) + const care = context.createdResources.phase4OverseasMasterData.vehicle.care + assert.equal(care.status, 'CHECKING') + assert.equal(care.records.length, 6) + assert.equal(care.otherVehicleStatus, 'INACTIVE') + assert.equal(state.spareVehicle.status, 'INACTIVE') + assert.ok(context.steps.some(step => step.name === '其他车辆不能读取或修改本车记录和照片' && !step.passed && step.deploymentBlocker)) +}) + +test('事件处理完成被错误映射为已付款时,真实验收编排必须阻断', async () => { + const { context } = fakeContext({ softAssertions: true, processingMarksPaid: true }) + await run(context) + assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING') + assert.ok(context.steps.some(step => step.name === '处理完成保持独立的费用及付款事实' && !step.passed && step.deploymentBlocker)) +}) + test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => { const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true }) await run(context) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 8230c10db..64180bb15 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -1,3 +1,5 @@ +import { verifyVehicleCare } from './vehicle-care.mjs' + const MODULE = 'phase4-overseas-master-data' const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' @@ -298,6 +300,7 @@ function vehiclePayload(context, edited = false) { plateNumber: `QA-${suffix}`.slice(0, 64), model: edited ? 'Phase4 QA Truck Edited' : 'Phase4 QA Truck', fuelConsumptionPer100Km: edited ? 18.75 : 18.5, + maxPalletSpaces: edited ? 28 : 30, maxLoadKg: edited ? 17500 : 18000, maxVolumeCbm: edited ? 88 : 90, mileageKm: edited ? 101 : 100, driverOrDispatchCompany: `${context.marker} Carrier`, phone: '12135550188', region: 'OVERSEAS', ownership: 'OWNED', @@ -306,6 +309,19 @@ function vehiclePayload(context, edited = false) { } } +export function vehicleCapacityMatches(actual, expected) { + return ['maxPalletSpaces', 'maxLoadKg', 'maxVolumeCbm'].every(key => + ['number', 'string'].includes(typeof actual?.[key]) && String(actual[key]).trim() !== '' + && Number.isFinite(Number(actual[key])) && Number(actual[key]) > 0 + && Number(actual[key]) === Number(expected[key])) +} + +function assertVehicleCapacity(context, name, response, actual, expected) { + context.assert(MODULE, name, response, vehicleCapacityMatches(actual, expected), + `palletSpaces=${actual?.maxPalletSpaces ?? 'missing'}, loadKg=${actual?.maxLoadKg ?? 'missing'}, volumeCbm=${actual?.maxVolumeCbm ?? 'missing'}`, + BLOCKER) +} + async function vehicleLifecycle(context, resource) { const createPayload = vehiclePayload(context) const createResponse = await context.request('POST', `${OVERSEAS_BASE}/vehicles`, createPayload) @@ -313,10 +329,12 @@ async function vehicleLifecycle(context, resource) { const id = asId(vehicle.id) if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion') resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status } + assertVehicleCapacity(context, '新建车辆保存三项实际容量', createResponse, vehicle, createPayload) const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}` const detailResponse = await context.request('GET', path) vehicle = requireData(context, '查询marker车辆详情', detailResponse) + assertVehicleCapacity(context, '车辆详情回显三项实际容量', detailResponse, vehicle, createPayload) context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse, asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status) && recordVersion(vehicle.recordVersion), @@ -329,6 +347,7 @@ async function vehicleLifecycle(context, resource) { const updatedVersion = Number(vehicle.recordVersion) const updatedMileage = Number(vehicle.mileageKm) resource.vehicle.recordVersion = updatedVersion + assertVehicleCapacity(context, '车辆编辑保持三项实际容量', updateResponse, vehicle, updatePayload) context.assert(MODULE, '车辆编辑版本递增', updateResponse, vehicle.model === updatePayload.model && updatedMileage === Number(updatePayload.mileageKm) && updatedVersion === Number(staleVersion) + 1, @@ -353,14 +372,18 @@ async function vehicleLifecycle(context, resource) { const listResponse = await context.request('GET', `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) const page = requireData(context, '分页查询marker车辆', listResponse) + assertVehicleCapacity(context, '车辆列表回显三项实际容量', listResponse, + page?.records?.find(item => asId(item.id) === id), updatePayload) context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse, Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) assertBinaryCsv(context, '导出marker车辆CSV', csv, - '车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间', - [id, createPayload.plateNumber, 'MAINTENANCE']) + '车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间', + [createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)]) + resource.vehicle.care = {} + await verifyVehicleCare(context, MODULE, vehicle, resource.vehicle.care) } async function verifyCanonicalPermissionDenial(context, resource) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 4753aff30..ad085d33a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -125,7 +125,8 @@ test('fixture failure still disables both picker accounts and removes all transi const steps = [] const handoff = { marker: 'PDAOPS-CONTRACT', - primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token' }, + primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token', + refreshToken: 'primary-secret-refresh', password: 'primary-secret-password', userUpdate: { phone: '13900000001' } }, secondary: { membershipId: '2', username: 'secondary', token: 'secondary-secret-token' }, merchantToken: 'merchant-secret-token', completedFixtures: [], @@ -144,6 +145,9 @@ test('fixture failure still disables both picker accounts and removes all transi assert.deepEqual(cleanups, ['secondary', 'primary']) assert.equal(handoff.primary.token, undefined) + assert.equal(handoff.primary.refreshToken, undefined) + assert.equal(handoff.primary.password, undefined) + assert.equal(handoff.primary.userUpdate, undefined) assert.equal(handoff.secondary.token, undefined) assert.equal(handoff.merchantToken, undefined) assert.equal(context.context.phase4OverseasPdaOperations, undefined) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 7afe31a09..765e56c5e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -1,4 +1,5 @@ import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs' +import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' const MODULE = 'phase4-overseas-pda-operations' const SCENARIO = 'phase4-overseas-pda-p2-p5' @@ -802,12 +803,17 @@ export async function run(context) { await verifyInventoryMove(context, token, fixture) await verifyPalletOperations(context, token, fixture) await verifyRelabel(context, token, merchantToken, fixture) + await verifyPdaWarehouseRevocation(context, handoff.primary) } finally { try { if (typeof handoff?.cleanup === 'function') await handoff.cleanup() } finally { - if (handoff?.primary) delete handoff.primary.token - if (handoff?.secondary) delete handoff.secondary.token + for (const picker of [handoff?.primary, handoff?.secondary]) if (picker) { + delete picker.token + delete picker.refreshToken + delete picker.password + delete picker.userUpdate + } if (handoff) delete handoff.merchantToken delete context.context.phase4OverseasPdaOperations } diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index d86984c1a..559961135 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -1,5 +1,6 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' import { run as createMerchant } from './merchant-qualification-fixture.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -378,7 +379,7 @@ export async function verifyFulfillmentRootStatus(context, { sourceOrderId, cust const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true const expectedCompleted = rootDelivered && pendingChildren.length === 0 const owned = context.createdResources.phase5OverseasFulfillment - if (owned) owned.orderCompletionProof = { sourceOrderId, rootOrderId, + if (owned) owned.orderCompletionProof = { customerId, sourceOrderId, rootOrderId, tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount), cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining, children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId), @@ -998,6 +999,7 @@ export async function run(context) { const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] + let tripProof if (dispatchInboundCodes.length === 0) { blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') await disablePicker(context, picker, 5) @@ -1022,6 +1024,7 @@ export async function run(context) { const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) if (!sourcesBeforeValid || !ledgerBeforePick) return + tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo) const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds) const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo) @@ -1031,13 +1034,15 @@ export async function run(context) { ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick }) const shippedRows = conservation.newLedger if (!pickedSuccessfully) return + const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) if (!ready) return - const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } + const departBody = { recordVersion: tripVersion, idempotencyKey: key(context, 'DISPATCH-DEPART').slice(0, 128), remark: `${context.marker}-depart` } const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, - departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, + departed.data?.status === 'IN_TRANSIT' && departed.data?.tripNo === tripProof.tripNo + && departed.data?.dispatches?.some(item => item.dispatchNo === dispatchNo) && Number(departed.data?.shippedPackages) > 0, trace(departed.response), OPTIONS) if (!success(departed.response)) return const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', @@ -1226,6 +1231,7 @@ export async function run(context) { const returnReceipt = await completeReturnedException(context, { customerId, warehouseId, exceptionNo: returnExceptionNo, outboundNo }) context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt + context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 28a651657..95ebf2c33 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -5,7 +5,7 @@ import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials, f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder, - verifyRootProjections, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs' + verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs' import { loadOneFragment } from './phase3-order-splitting.mjs' import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, @@ -32,6 +32,50 @@ const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdRe addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data}, }) +function remainderTaxFixture(taxMode) { + const context=fixture(),calls=[] + const evidence={taxMode,customerId:'90',rootOrderId:'100',childOrderId:'101',rootOrderNo:'ORDER-ROOT', + rawChild:{id:'101',tenantId:'90',taxMode},rootInbound:{orderId:'100',taxMode}, + childInbound:{orderId:'101',tenantId:'90',taxMode}} + const rows=[{orderId:'100',tenantId:'90',taxMode,relation:{role:'PARENT',rootOrderId:'100'}}, + {orderId:'101',tenantId:'90',taxMode,relation:{role:'CHILD',rootOrderId:'100',parentOrderId:'100'}}] + context.request=async(method,path,body,token)=>{ + calls.push({method,path,body,token}) + const filter=new URL(path,'https://offline.invalid').searchParams.get('taxMode') + const records=filter&&filter!==taxMode?[]:structuredClone(rows) + return {http:200,body:{code:'200',data:{records,total:records.length}}} + } + return {context,evidence,calls,rows} +} +test('raw remainder and actual inbound summary taxMode agree for BUY and TAX and customer filters retain exact parent/child',async()=>{ + for(const mode of ['BUY_DECLARATION','TAX_REFUND']){ + const f=remainderTaxFixture(mode) + await verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer') + assert.equal(f.calls.length,3) + assert.ok(f.calls.every(c=>c.method==='GET'&&c.token==='own-customer'&&!c.path.includes('/customs'))) + assert.deepEqual(f.evidence.matchingTax.records.map(r=>r.orderId),['100','101']) + assert.equal(f.evidence.otherTax.total,0) + assert.match(f.calls[1].path,new RegExp('taxMode='+mode)) + } +}) +test('missing or wrong raw remainder taxMode is rejected before customer/customs calls despite valid inbound projection',async()=>{ + for(const mode of ['BUY_DECLARATION','TAX_REFUND'])for(const actual of [null,undefined,mode==='BUY_DECLARATION'?'TAX_REFUND':'BUY_DECLARATION']){ + const f=remainderTaxFixture(mode);f.evidence.rawChild.taxMode=actual + await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'),/原始余量子订单继承/) + assert.equal(f.calls.length,0) + } +}) +test('wrong inbound tax, missing filtered child, foreign customer or wrong parent identity cannot pass',async()=>{ + for(const mutate of [f=>f.evidence.childInbound.taxMode=null,f=>f.rows.pop(),f=>f.rows[1].tenantId='91', + f=>f.rows[1].relation.parentOrderId='999',f=>f.rows[1].taxMode='BUY_DECLARATION']) { + const f=remainderTaxFixture('TAX_REFUND');mutate(f) + await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer')) + } + const f=remainderTaxFixture('TAX_REFUND'),original=f.context.request + f.context.request=async(...args)=>{const r=await original(...args);if(args[1].includes('taxMode=BUY_DECLARATION'))r.body.data={records:[f.rows[1]],total:1};return r} + await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'),/otherTax/) +}) + test('F40 unified projections require exact root and customer session with final COMPLETED or unfinished DELIVERED',async()=>{ for (const completed of [true,false]) { const context=fixture(),calls=[],snapshot={root:{status:completed?'COMPLETED':'TRANSPORTING',histories:history('OVERSEAS_DELIVERED')}} @@ -232,7 +276,7 @@ test('exported consumer polling and conversion fail closed when real producer co test('exported receiving rejects unmatched current producer instead of borrowing any history container',async() => { const context=fixture();context.request=async(_method,path) => ({http:200,body:{code:200,data: path.endsWith('/candidates')?[{containerNo:'ANOTHER',sourceContainerBookingId:'999',totalPackages:1}]:null}}) - await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false) + await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false,{attempts:1}) assert.equal(context.steps.some(step => step.passed === false),true) }) test('exported receiving warehouse resolver continues to choose only configured active official warehouse',async() => { diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 8b3571905..440cf455f 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -6,6 +6,8 @@ import { awaitProducerConsumption, convertReceivingContainers, runReceiving, res resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' +import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' const MODULE = 'phase5-root-fulfillment' const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true } @@ -18,6 +20,7 @@ const query = (path, values) => `${path}?${new URLSearchParams(values)}` export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) } export function preserveReceivingEvidence(proof, scope) { proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {} + proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || [] } export function validFreshReceiptSave(before, after) { const previousVersion = Number(before?.documents?.version) @@ -234,6 +237,36 @@ export async function verifyRootProjections(context, customerId, rootOrderId, me && customer.summary.mainStatus === platform.summary.mainStatus,'exact-root-platform-customer-status') return {platform:platform.summary,customer:customer.summary} } +export async function verifyRemainderTaxIdentity(context, evidence, merchantToken) { + const {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo,rawChild,rootInbound,childInbound}=evidence + check(context,'F40 余量税制核对范围完整',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode) + && Boolean(rootOrderNo && customerId && rootOrderId && childOrderId),'exact-current-remainder-scope') + check(context,'F40 原始余量子订单继承本轮税制',rawChild?.taxMode===taxMode + && id(rawChild.id)===childOrderId && id(rawChild.tenantId)===customerId, + `expected=${taxMode}, actual=${rawChild?.taxMode || 'missing'}`) + check(context,'F40 根与余量入仓摘要税制和来源身份一致',rootInbound?.taxMode===taxMode + && childInbound?.taxMode===taxMode && id(rootInbound.orderId)===rootOrderId + && id(childInbound.orderId)===childOrderId && id(childInbound.tenantId)===customerId, + `rootTax=${rootInbound?.taxMode || 'missing'}, childTax=${childInbound?.taxMode || 'missing'}`) + const exactRows = page => Array.isArray(page?.records) && page.records.length===2 && Number(page.total)===2 + && new Set(page.records.map(row=>id(row.orderId))).size===2 + && page.records.every(row=>[rootOrderId,childOrderId].includes(id(row.orderId)) + && id(row.tenantId)===customerId && row.taxMode===taxMode + && id(row.relation?.rootOrderId)===rootOrderId + && (id(row.orderId)===rootOrderId?row.relation?.role==='PARENT' + :row.relation?.role==='CHILD'&&id(row.relation?.parentOrderId)===rootOrderId)) + for(const [name,filter] of [['unfiltered',null],['matchingTax',taxMode], + ['otherTax',taxMode==='TAX_REFUND'?'BUY_DECLARATION':'TAX_REFUND']]) { + const values={orderNo:rootOrderNo,page:'1',size:'200'} + if(filter)values.taxMode=filter + evidence[name]=await api(context,'GET',`F40 客户统一订单税制筛选对照${name}`, + query('/api/merchant/v1/orders',values),undefined,merchantToken) + check(context,`F40 客户筛选${name}精确覆盖本轮父子身份`,name==='otherTax' + ? Array.isArray(evidence[name]?.records)&&evidence[name].records.length===0&&Number(evidence[name].total)===0 + : exactRows(evidence[name]),`taxMode=${filter || 'unfiltered'}`) + } + return evidence +} export async function cancelUnloadedRemainder(context, proof, customerId, rootOrderId, childOrderId, rootInboundId, childInboundId) { proof.currentStage = 'REMAINDER_CANCEL_PRECONDITION' @@ -337,10 +370,12 @@ async function fragment(context, source, rootOrderId, sequence) { } context.createdResources.phase3Loading = loading context.createdResources.orders = { ...(context.createdResources.orders || {}), ...loading, - taxMode: 'BUY_DECLARATION' } + taxMode: context.context.f40TaxMode } const palletLabelManifest = await verifyPalletLabels(context, loading, result.planId) check(context, 'F40 来源柜散货托盘码来自真实准备接口', Boolean(palletLabelManifest?.totalCount), `sequence=${sequence}`) - for (const name of ['phase3-customs', 'service-center-flow', 'phase3-port-schedules', + if(context.context.f40TaxMode==='TAX_REFUND') await prepareTaxCustoms(context,loading) + else await stage(context,'phase3-customs') + for (const name of ['service-center-flow', 'phase3-port-schedules', 'phase3-outbound-dispatch', 'phase3-container-documents', 'phase3-carrier-schedule-queries']) await stage(context, name) const nodes = await import('./phase4-carrier-nodes.mjs') await nodes.runUntilPickupReady(context) @@ -379,11 +414,14 @@ async function sendFragment(context, producer, picker, merchantToken, index) { && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) context.marker = `${context.context.f40BaseMarker}-SEND-${index}` + const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, picker.warehouseId) check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`, - { idempotencyKey: `${context.marker}-DEPART`, remark: context.marker }, picker.token) - check(context, 'F40 正式发车已 DEPARTED', departed.status === 'DEPARTED', `dispatch=${dispatchNo}`) + { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }, picker.token) + check(context, 'F40 整车发车包含本片排车', departed.status === 'IN_TRANSIT' && departed.tripNo === tripProof.tripNo + && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), `dispatch=${dispatchNo}`) const outbound = await api(context, 'GET', '回查实际来源出库完成', query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' })) check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED' @@ -397,8 +435,9 @@ async function sendFragment(context, producer, picker, merchantToken, index) { const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) + const tripClosure = await finishFleetTrip(context, MODULE, tripProof) return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, - receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed } + receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } } /** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ @@ -426,11 +465,14 @@ async function runRootFulfillment(context) { ['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'].includes(signatureOrder), signatureOrder) const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED' proof.signatureOrder = signatureOrder + const taxMode=context.context?.f40TaxMode||'BUY_DECLARATION' + check(context,'F40 税制必须为显式支持值',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode),taxMode) + proof.taxMode=taxMode;proof.taxCustoms=[] const scope = Object.create(context) scope.marker = `${context.marker}-F40` scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', orderStopAtApproved:cancelRemainder, - orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' }, + orderProfile: { taxMode, fulfillmentType: 'STANDARD' },f40TaxMode:taxMode,f40TaxEvidence:proof.taxCustoms, waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, phase4OverseasPdaOperations: { completedFixtures: [] }, allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` } @@ -443,6 +485,7 @@ async function runRootFulfillment(context) { const profile = await api(scope,'GET','F40 本轮客户会话归属核验', '/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken) const expectedCustomerId = id(profile.id) + if(taxMode==='TAX_REFUND')scope.context.f40TaxMerchantToken=login.accessToken check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId) && (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer') await domesticLogin(scope) @@ -490,6 +533,9 @@ async function runRootFulfillment(context) { check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`) const childInbound = await api(scope, 'GET', '读取子单可装柜来源', query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) + proof.remainderTaxIdentity = {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo:orderFixture.orderNo, + rawChild:childDetail.order,rootInbound:rootInbound.summary,childInbound:childInbound.summary} + await verifyRemainderTaxIdentity(scope,proof.remainderTaxIdentity,login.accessToken) let second = null if (cancelRemainder) { check(scope,'F40 取消专项真实余量未装且原始 APPROVED', @@ -628,6 +674,7 @@ async function runRootFulfillment(context) { 'exact-root/source/customer/readOnly-proof') check(scope,'F40 新键重复签收完成Outbox数量与发布状态零漂移', outboxCounts(proof.beforeReceiptReplayOutbox) === outboxCounts(proof.outboxProof),'before-after-receipt-replay-outbox') + if(taxMode==='TAX_REFUND')await verifyTaxDocumentsAfterCompletion(scope,proof.taxCustoms,login.accessToken) proof.finalStatus='VERIFIED' } else { proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING' diff --git a/scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs new file mode 100644 index 000000000..de01d2a05 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {createHash} from 'node:crypto' +import {readFileSync} from 'node:fs' +import {prepareTaxCustoms,verifyTaxDocumentsAfterCompletion,taxDocumentMatches} from './phase5-root-tax-customs.mjs' +const loading={tenantId:'90',orderId:'100',inboundId:'200',loadingPlanId:'300',containerBookingId:'400',inboundCode:'IN-TAX',containerNo:'TAX-BOX'} +function fixture(){ + const calls=[],files={},evidence=[],view={version:0,status:'READY_TO_START',loadingPlanId:'300',containerBookingId:'400', + inbounds:[{inboundId:'200',taxMode:'TAX_REFUND'}],documents:[]} + const wrap=data=>({http:200,body:{code:'200',data:structuredClone(data)}}) + const context={marker:'OFFLINE-TAX',token:'platform',createdResources:{},context:{f40TaxMerchantToken:'customer',f40TaxEvidence:evidence}, + assert:(_m,name,_r,passed)=>{assert.equal(Boolean(passed),true,name)},apiSuccess:(_m,_n,r)=>r.body?.data, + request:async(method,path,body,token)=>{ + calls.push({method,path,body,token}) + if(path==='/api/merchant/v1/orders/100')return wrap({summary:{orderId:'100',mainStatus:'COMPLETED',taxMode:'TAX_REFUND'}}) + if(method==='GET')return wrap(view) + if(path.endsWith('/start')){view.status='DECLARING';view.documents[0].frozen=true} + if(path.endsWith('/receipt/confirm'))view.documents[0].receiptStatus='CONFIRMED' + if(path.endsWith('/declaration-batches'))return wrap({id:'500',version:0,status:'READY'}) + if(path.endsWith('/500/submit'))return wrap({version:1,status:'SUBMITTED'}) + if(path.endsWith('/500/results'))return wrap({resultType:'RELEASED',resultStatus:'RESOLVED'}) + view.version++;return wrap(view) + }, + requestMultipart:async(_method,path,file,token)=>{ + calls.push({method:'UPLOAD',path,token}) + const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';files[kind]=file.content + const metadata={fileHash:createHash('sha256').update(file.content).digest('hex'),version:1} + if(kind==='source')view.documents.push({documentId:'600',tenantId:'90',inboundIds:['200'],source:metadata,frozen:false}) + else view.documents[0][kind==='final'?'finalDocument':kind]=metadata + if(kind==='final')view.status='COMPLETED' + return wrap(view.documents[0]) + }, + async requestBinary(_method,path){assert.equal(this.token,'customer');const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';return {http:200,bytes:files[kind]}}, + } + return {context,calls,files,evidence,view} +} +test('tax F40 uses actual upload confirm declaration release and final before post-COMPLETED customer downloads',async()=>{ + const f=fixture();const proof=await prepareTaxCustoms(f.context,loading) + assert.equal(proof.status,'CUSTOMS_COMPLETED') + await verifyTaxDocumentsAfterCompletion(f.context,f.evidence,'customer') + assert.equal(proof.status,'ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED') + assert.deepEqual(proof.downloads.map(x=>x.kind),['source','receipt','final']) + const paths=f.calls.filter(c=>c.method!=='GET').map(c=>c.path) + let last=-1 + for(const suffix of ['/documents/upload?inboundIds=200','/start','/send-to-broker','/receipt/upload?expectedVersion=0', + '/receipt/confirm','/submit-to-broker','/declaration-batches','/500/submit','/500/results','/final/upload?expectedVersion=0']){ + const index=paths.findIndex((p,i)=>i>last&&p.endsWith(suffix));assert.ok(index>last,suffix);last=index + } + assert.equal(f.calls.find(c=>c.path.endsWith('/receipt/confirm')).token,'customer') + assert.equal(JSON.stringify(proof).includes('customer'),true) // customerId evidence, never session token field + assert.equal(Object.hasOwn(proof,'token'),false) + assert.doesNotMatch(JSON.stringify(proof),/"(?:bytes|password|accessToken|refreshToken|authorization)"\s*:/i) +}) +test('customer tax evidence rejects changed customer, source, plan, file bytes and still-uncompleted order',async()=>{ + const f=fixture(),proof=await prepareTaxCustoms(f.context,loading) + for(const mutate of [v=>v.status='DECLARING',v=>v.loadingPlanId='999',v=>v.documents[0].tenantId='91', + v=>v.documents[0].inboundIds=['201'],v=>v.documents[0].finalDocument.fileHash='changed']){ + const v=structuredClone(f.view);mutate(v);assert.equal(taxDocumentMatches(v,proof),false) + } + f.files.final=new TextEncoder().encode('incorrect bytes') + await assert.rejects(verifyTaxDocumentsAfterCompletion(f.context,[proof],'customer'),/final字节/) +}) +test('buy remains the default; tax path is explicit and final verification occurs after Outbox proof',()=>{ + const source=readFileSync(new URL('./phase5-root-fulfillment.mjs',import.meta.url),'utf8') + assert.match(source,/f40TaxMode\|\|'BUY_DECLARATION'/) + assert.match(source,/f40TaxMode==='TAX_REFUND'\) await prepareTaxCustoms/) + assert.match(source,/else await stage\(context,'phase3-customs'\)/) + assert.ok(source.indexOf('await verifyTaxDocumentsAfterCompletion')>source.indexOf('proof.outboxProof =')) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs new file mode 100644 index 000000000..59e069b02 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs @@ -0,0 +1,97 @@ +import { createHash } from 'node:crypto' +const MODULE='phase5-root-fulfillment',OPTIONS={scenario:'f40-tax-customs-after-completion',deploymentBlocker:true} +const PLATFORM='/api/platform/v1/customs/loading-plans',MERCHANT='/api/merchant/v1/customs' +const id=value=>String(value??'') +const sha=bytes=>createHash('sha256').update(bytes).digest('hex') +function check(context,name,passed,response=null){ + context.assert(MODULE,name,response,Boolean(passed),'exact-current-tax-source',OPTIONS) + if(!passed)throw Error(name) +} +async function api(context,method,name,path,body,token=context.token,key){ + const response=await context.request(method,path,body,token,key?{'Idempotency-Key':key}:{}) + const data=context.apiSuccess(MODULE,name,response,'',OPTIONS) + check(context,name,response?.http===200&&String(response.body?.code)==='200',response) + return data +} +export function taxDocumentMatches(view,proof){ + const documents=view?.documents||[] + const document=documents.find(row=>id(row.documentId)===proof.documentId) + return view?.status==='COMPLETED'&&id(view.loadingPlanId)===proof.loadingPlanId + &&id(view.containerBookingId)===proof.containerBookingId&&document?.frozen===true + &&id(document.tenantId)===proof.customerId&&document.inboundIds?.map(id).includes(proof.inboundId) + &&document.source?.fileHash===proof.files.source.sha256&&document.receiptStatus==='CONFIRMED' + &&document.receipt?.fileHash===proof.files.receipt.sha256&&document.finalDocument?.fileHash===proof.files.final.sha256 +} +export async function prepareTaxCustoms(context,loading){ + const proof={customerId:id(loading.tenantId),orderId:id(loading.orderId),inboundId:id(loading.inboundId), + inboundCode:loading.inboundCode,containerBookingId:id(loading.containerBookingId),loadingPlanId:id(loading.loadingPlanId), + containerNo:loading.containerNo,files:{},stages:[],status:'PREPARING'} + context.context.f40TaxEvidence.push(proof) + const token=context.context.f40TaxMerchantToken + check(context,'F40 退税报关使用本轮客户会话',Boolean(token)) + const base=`${PLATFORM}/${proof.loadingPlanId}` + const key=action=>`${context.marker}-F40-TAX-${action}`.slice(0,128) + const detail=()=>api(context,'GET','F40 回查真实退税报关聚合',base) + const upload=async(kind,path,token)=>{ + const content=new TextEncoder().encode(`${context.marker} TAX_REFUND ${proof.inboundCode} ${kind}`) + proof.files[kind]={sha256:sha(content),byteCount:content.length} + const response=await context.requestMultipart('POST',path,{name:`${context.marker}-${kind}.txt`,type:'text/plain',content},token,{'Idempotency-Key':key(kind)}) + const data=context.apiSuccess(MODULE,`F40 真实上传退税${kind}文件`,response,'',OPTIONS) + check(context,`F40 退税${kind}上传成功`,response.http===200&&String(response.body?.code)==='200',response) + return data + } + let current=await detail() + check(context,'F40 实装来源确为本客户退税入仓码',current.inbounds?.length===1&¤t.inbounds.every(row=> + row.taxMode==='TAX_REFUND'&&id(row.inboundId)===proof.inboundId)) + const source=await upload('source',`${MERCHANT}/loading-plans/${proof.loadingPlanId}/documents/upload?inboundIds=${proof.inboundId}`,token) + proof.documentId=id(source.documentId) + check(context,'F40 客户原始报关文件精确绑定本票',Boolean(proof.documentId)&&source.source?.fileHash===proof.files.source.sha256 + &&id(source.tenantId)===proof.customerId&&source.inboundIds?.map(id).includes(proof.inboundId)) + for(const action of ['start','send-to-broker']){ + current=await detail() + const next=await api(context,'POST',`F40 退税真实${action}`,`${base}/${action}`, + {expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key(action)) + proof.stages.push({action,status:next.status,version:next.version}) + } + const receipt=await upload('receipt',`${PLATFORM}/documents/${proof.documentId}/receipt/upload?expectedVersion=0`,context.token) + await api(context,'POST','F40 客户确认本票报关回执',`${MERCHANT}/documents/${proof.documentId}/receipt/confirm`, + {expectedVersion:Number(receipt.receipt.version),confirm:true,remark:context.marker},token,key('confirm')) + current=await detail() + await api(context,'POST','F40 平台发送已确认回执执行报关',`${base}/submit-to-broker`, + {expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key('execute')) + current=await detail() + const batch=await api(context,'POST','F40 人工创建真实正式申报批次',`${base}/declaration-batches`, + {expectedVersion:Number(current.version),batchNo:`${context.marker}-DECL`.slice(-64),remark:context.marker},context.token,key('declaration')) + proof.declarationBatchId=id(batch.id) + const submitted=await api(context,'POST','F40 人工提交正式申报',`${PLATFORM}/declaration-batches/${batch.id}/submit`, + {expectedVersion:Number(batch.version),customsEntryNo:`F40TAX-${proof.loadingPlanId}`,remark:context.marker},context.token,key('submit-declaration')) + const now=new Date(),local=new Date(now.getTime()-now.getTimezoneOffset()*60000).toISOString().slice(0,19) + const release=await api(context,'POST','F40 人工记录海关放行',`${PLATFORM}/declaration-batches/${batch.id}/results`, + {expectedVersion:Number(submitted.version),resultType:'RELEASED',occurredAt:local,resolution:'本轮退税验收人工放行',resolved:true},context.token,key('release')) + check(context,'F40 真实申报已提交且放行已处理',submitted.status==='SUBMITTED'&&release.resultType==='RELEASED'&&release.resultStatus==='RESOLVED') + await upload('final',`${PLATFORM}/documents/${proof.documentId}/final/upload?expectedVersion=0`,context.token) + proof.completedCustoms=await detail() + check(context,'F40 退税完成文件齐全才完成报关',taxDocumentMatches(proof.completedCustoms,proof)) + proof.status='CUSTOMS_COMPLETED' + context.createdResources.phase3Customs={...loading,parentOrderId:loading.orderId,finalStatus:'CLEARANCE_COMPLETED'} + return proof +} +export async function verifyTaxDocumentsAfterCompletion(context,proofs,merchantToken){ + check(context,'F40 完成后验收具有真实退税报关证据',proofs.length>0) + for(const proof of proofs){ + const order=await api(context,'GET','F40 客户回查退税原订单最终完成',`/api/merchant/v1/orders/${proof.orderId}`,undefined,merchantToken) + check(context,'F40 退税订单最终COMPLETED且税制未变',id(order.summary?.orderId)===proof.orderId&&order.summary.mainStatus==='COMPLETED'&&order.summary.taxMode==='TAX_REFUND') + proof.afterCompletedView=await api(context,'GET','F40 COMPLETED后客户仍可读取本票报关资料',`${MERCHANT}/orders/${proof.orderId}`,undefined,merchantToken) + check(context,'F40 完成后报关资料客户范围及文件哈希保持一致',taxDocumentMatches(proof.afterCompletedView,proof)) + proof.downloads=[] + const customer=Object.create(context);customer.token=merchantToken + for(const [kind,suffix] of [['source','download'],['receipt','receipt/download'],['final','final/download']]){ + const response=await customer.requestBinary('GET',`${MERCHANT}/documents/${proof.documentId}/${suffix}`) + const fact={kind,http:response.http,byteCount:response.bytes?.length||0,sha256:sha(response.bytes||new Uint8Array())} + proof.downloads.push(fact) + check(context,`F40 COMPLETED后客户真实下载${kind}字节保持一致`,fact.http===200&&fact.byteCount===proof.files[kind].byteCount&&fact.sha256===proof.files[kind].sha256, + {...response,bytes:undefined}) + } + proof.status='ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED' + } +} diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 45add7123..ba7028919 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -1,5 +1,23 @@ function id(value) { return value == null ? '' : String(value) } +export async function verifyRevokedSessions(context, reason, sessions, accessPath = '/api/platform/v1/orders/status') { + const options = { scenario: 'unified-session-revocation', deploymentBlocker: true } + for (const [index, session] of sessions.entries()) { + const complete = Boolean(session?.accessToken && session?.refreshToken) + context.assert('system', `${reason}第${index + 1}组会话凭据完整`, null, complete, + `session=${index + 1}`, options) + if (!complete) throw new Error('会话撤销检查缺少完整 Access/Refresh 对') + const access = await context.request('GET', accessPath, undefined, session.accessToken) + context.assert('system', `${reason}旧Access失效`, access, + access.http === 401 && String(access.body?.code) === 'A0401', + `session=${index + 1}, HTTP=${access.http}, code=${access.body?.code || 'missing'}`, options) + const refresh = await context.request('POST', '/api/auth/refresh', { refreshToken: session.refreshToken }, '') + context.assert('system', `${reason}旧Refresh不能恢复会话`, refresh, + [200, 401].includes(refresh.http) && String(refresh.body?.code) === 'A0401', + `session=${index + 1}, HTTP=${refresh.http}, code=${refresh.body?.code || 'missing'}`, options) + } +} + export async function run(context) { const module = 'system' const suffix = context.marker.replace(/\D/g, '').slice(-12) @@ -140,9 +158,12 @@ export async function run(context) { const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '') const loginPassed = loginResponse.http === 200 && String(loginResponse.body?.code) === '200' && Boolean(loginResponse.body?.data?.accessToken) context.addStep(module, '新建内部用户可使用初始密码登录', loginResponse, loginPassed, `username=${username}`) + const secondLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '') + const secondLogin = context.apiSuccess(module, '建立同账号第二个独立会话', secondLoginResponse) const resetResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/password`, { newPassword: resetPassword }) context.apiSuccess(module, '重置平台内部用户密码', resetResponse) + await verifyRevokedSessions(context, '密码重置撤销多端会话', [loginResponse.body?.data, secondLogin]) const resetLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) @@ -161,16 +182,20 @@ export async function run(context) { } } + const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] }) + context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse) + await verifyRevokedSessions(context, '角色权限清空撤销已登录会话', [resetLoginResponse.body?.data]) + const activeLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') + const activeLogin = context.apiSuccess(module, '按更新后的权限重新登录', activeLoginResponse) const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' }) const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse) context.assert(module, '内部用户停用状态正确', disableUserResponse, disabledUser?.status === 'DISABLED', `status=${disabledUser?.status ?? 'missing'}`) + await verifyRevokedSessions(context, '账号停用撤销会话', [activeLogin]) const disabledLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') context.addStep(module, '停用用户禁止登录', disabledLoginResponse, disabledLoginResponse.http === 200 && String(disabledLoginResponse.body?.code) !== '200', `HTTP=${disabledLoginResponse.http}, code=${disabledLoginResponse.body?.code ?? 'missing'}`) - const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] }) - context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse) const deleteRoleResponse = await context.request('DELETE', `/api/platform/v1/iam/roles/${roleId}`) context.expectConflict(module, '已分配成员的角色禁止删除', deleteRoleResponse, 'system-role-protection') const finalDisableRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' }) diff --git a/scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs b/scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs new file mode 100644 index 000000000..38994969c --- /dev/null +++ b/scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { verifyPdaWarehouseRevocation, warehouseClaim } from './unified-pda-session-revocation.mjs' +import { pickerSessionHandoff } from './phase4-overseas-pda.mjs' + +const token = warehouse => 'header.' + Buffer.from(JSON.stringify({ overseas_warehouse_id: warehouse })).toString('base64url') + '.signature' +test('signed binding identity retains 64-bit decimal precision for numeric and string claims', () => { + const make = raw => 'header.' + Buffer.from(raw).toString('base64url') + '.signature' + assert.equal(warehouseClaim(make('{"overseas_warehouse_id":193000000000200004}')), '193000000000200004') + assert.equal(warehouseClaim(token('193000000000200004')), '193000000000200004') + assert.equal(warehouseClaim(make('{"overseas_warehouse_id":null}')), '') + assert.equal(warehouseClaim(make('{"overseas_warehouse_id":22,"overseas_warehouse_id":23}')), '') +}) +function fixture(wrongWarehouse = false) { + const checks = [], calls = [] + let cleared = false + let isolatedRole + const picker = { membershipId: '7', username: 'offline-user', password: 'offline-password', + token: 'old-access', refreshToken: 'old-refresh', userUpdate: { displayName: 'offline', phone: '13900000001', + email: 'offline@example.invalid', tenantId: '0', roleIds: ['9'], overseasWarehouseId: '11', domesticWarehouseId: null } } + const response = (data, code = '200', http = 200) => ({ http, body: { code, data } }) + const context = { assert: (_m, name, _r, passed, evidence) => checks.push({ name, passed, evidence }), + apiSuccess: (_m, _n, r) => r.body.code === '200' ? r.body.data : null, + request: async (method, path, body, bearer) => { + calls.push({ method, path, body, bearer }) + if (path.includes('receiving-options')) return response({ records: [{ id: '11' }, { id: '22' }] }) + if (path === '/api/platform/v1/iam/roles' && method === 'POST') { + isolatedRole = { ...body, id: '99', status: 'ACTIVE', permissionIds: [] } + return response(isolatedRole) + } + if (path === '/api/platform/v1/iam/roles') return response([isolatedRole]) + if (path === '/api/platform/v1/iam/roles/99/status') return response({ status: 'DISABLED' }) + if (method === 'PUT') { + assert.equal(body.phone, picker.userUpdate.phone) + assert.equal(body.email, picker.userUpdate.email) + assert.ok(body.roleIds.length > 0, 'IAM user edits require a role') + cleared = body.roleIds.includes('99') + return response({ ...body }) + } + if (path === '/api/auth/login') return response({ accessToken: token(cleared ? null : wrongWarehouse ? '11' : '22'), refreshToken: 'new-refresh' }) + if (path === '/api/auth/refresh') { + assert.equal(bearer, '') + return response(null, 'A0401') + } + if (bearer === 'old-access' || (cleared && bearer === token('22'))) return response(null, 'A0401', 401) + return cleared ? response(null, 'A0403', 403) : response(null, 'A0404') + } } + return { context, picker: pickerSessionHandoff(picker), checks, calls } +} +test('binding changes revoke both tokens and role/binding clearing prevents fresh PDA access', async () => { + const { context, picker, checks, calls } = fixture() + await verifyPdaWarehouseRevocation(context, picker) + for (const name of ['PDA仓绑定变更旧Access失效', 'PDA仓绑定变更旧Refresh不能恢复会话', + '新PDA会话只携带服务器签发的新仓绑定', 'PDA角色与仓绑定清空旧Access失效', + 'PDA角色与仓绑定清空旧Refresh不能恢复会话', '无作业仓绑定的新会话不得进入PDA']) { + assert.equal(checks.find(row => row.name === name)?.passed, true, name) + } + assert.ok(checks.every(row => row.passed)) + assert.ok(checks.every(row => !/offline-password|old-refresh|new-refresh/.test(row.evidence || ''))) + assert.equal(calls.filter(row => row.method === 'PUT' && row.path.includes('/iam/users/')).length, 2) + assert.ok(calls.some(row => row.path === '/api/platform/v1/iam/roles/99/status')) + assert.ok(calls.some(row => row.path === '/api/auth/refresh' && row.body.refreshToken === 'old-refresh')) +}) +test('fresh login retaining the original warehouse blocks instead of accepting token presence', async () => { + const { context, picker, checks } = fixture(true) + await assert.rejects(verifyPdaWarehouseRevocation(context, picker), /绑定漂移/) + assert.ok(checks.some(row => !row.passed)) +}) diff --git a/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs b/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs new file mode 100644 index 000000000..e1a92802a --- /dev/null +++ b/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs @@ -0,0 +1,77 @@ +import { verifyRevokedSessions } from './system.mjs' + +const MODULE = 'phase4-overseas-pda-operations' +const OPTIONS = { scenario: 'unified-pda-session-revocation', deploymentBlocker: true } +const PATH = '/api/pda/v1/last-leg/receiving-containers/current' +const id = value => value == null ? '' : String(value) +const success = value => value?.http === 200 && String(value?.body?.code) === '200' +export function warehouseClaim(token) { + try { + const raw = Buffer.from(token.split('.')[1], 'base64url').toString('utf8') + JSON.parse(raw) // Check JSON shape; obtain the ID from its original decimal lexeme. + const matches = [...raw.matchAll(/"overseas_warehouse_id"\s*:\s*(?:"([1-9][0-9]*)"|([1-9][0-9]*)(?=\s*[,}]))/g)] + return matches.length === 1 ? matches[0][1] || matches[0][2] : '' + } + catch { return '' } +} + +// Called only after the owned actor's inventory operations finish. Credentials +// stay in the private handoff context and are never added to createdResources. +export async function verifyPdaWarehouseRevocation(context, picker) { + if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料') + const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200') + const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS) + const next = page?.records?.find(row => id(row.id) !== id(picker.userUpdate.overseasWarehouseId)) + if (!next?.id) throw Error('PDA仓绑定变更缺少第二个正式收货仓') + const updatePath = `/api/platform/v1/iam/users/${picker.membershipId}` + const updated = await context.request('PUT', updatePath, { ...picker.userUpdate, overseasWarehouseId: id(next.id) }) + context.assert(MODULE, '仅变更本轮PDA账号的海外仓绑定', updated, + success(updated) && id(updated.body.data.overseasWarehouseId) === id(next.id) + && id(updated.body.data.phone) === id(picker.userUpdate.phone), `warehouseId=${id(next.id)}`, OPTIONS) + if (!success(updated)) throw Error('PDA仓绑定更新失败') + await verifyRevokedSessions(context, 'PDA仓绑定变更', [{ accessToken: picker.token, refreshToken: picker.refreshToken }], PATH) + const loggedIn = await context.request('POST', '/api/auth/login', { loginId: picker.username, password: picker.password }, '') + const session = context.apiSuccess(MODULE, 'PDA按新仓绑定重新登录', loggedIn, '', OPTIONS) + const valid = session?.accessToken && session?.refreshToken && warehouseClaim(session.accessToken) === id(next.id) + context.assert(MODULE, '新PDA会话只携带服务器签发的新仓绑定', loggedIn, Boolean(valid), `warehouseId=${id(next.id)}`, OPTIONS) + if (!valid) throw Error('PDA新仓会话身份不完整或绑定漂移') + const allowed = await context.request('GET', PATH, undefined, session.accessToken) + context.assert(MODULE, '新仓绑定会话可进入PDA且不借旧仓会话', allowed, + success(allowed) || (allowed.http === 200 && String(allowed.body?.code) === 'A0404'), + `HTTP=${allowed.http}, code=${allowed.body?.code || 'missing'}`, OPTIONS) + const roleCode = `PDA_UNBOUND_${picker.membershipId}` + const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', + { roleCode, roleName: `无PDA权限验收-${picker.membershipId}`, description: context.marker }) + const role = context.apiSuccess(MODULE, '创建本轮无PDA权限的隔离角色', roleResponse, '', OPTIONS) + if (!role?.id) throw Error('无PDA权限验收角色创建失败') + try { + const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') + const roles = context.apiSuccess(MODULE, '核对隔离角色未获任何权限', rolesResponse, '', OPTIONS) + const isolated = roles?.find(row => id(row.id) === id(role.id)) + const emptyPermissions = isolated?.roleCode === roleCode && isolated?.status === 'ACTIVE' + && Array.isArray(isolated.permissionIds) && isolated.permissionIds.length === 0 + context.assert(MODULE, '清空仓绑定使用本轮无权限角色', rolesResponse, emptyPermissions, `roleId=${id(role.id)}`, OPTIONS) + if (!emptyPermissions) throw Error('无PDA权限验收角色身份或权限不符合要求') + const cleared = await context.request('PUT', updatePath, + { ...picker.userUpdate, roleIds: [id(role.id)], overseasWarehouseId: null, domesticWarehouseId: null }) + context.assert(MODULE, '取消本轮PDA角色同时清空作业仓绑定', cleared, + success(cleared) && cleared.body.data.overseasWarehouseId == null && cleared.body.data.domesticWarehouseId == null + && cleared.body.data.roleIds?.length === 1 && id(cleared.body.data.roleIds[0]) === id(role.id), '', OPTIONS) + if (!success(cleared)) throw Error('PDA角色及仓绑定清空失败') + await verifyRevokedSessions(context, 'PDA角色与仓绑定清空', [session], PATH) + const noBindingLogin = await context.request('POST', '/api/auth/login', { loginId: picker.username, password: picker.password }, '') + const token = success(noBindingLogin) ? noBindingLogin.body.data?.accessToken : null + if (token) { + const denied = await context.request('GET', PATH, undefined, token) + context.assert(MODULE, '无作业仓绑定的新会话不得进入PDA', denied, + denied.http === 403 || (denied.http === 200 && String(denied.body?.code) === 'A0403'), '', OPTIONS) + } else { + context.assert(MODULE, '无PDA身份不能重新取得作业会话', noBindingLogin, + [200, 401, 403].includes(noBindingLogin.http) && ['A0401', 'A0403'].includes(String(noBindingLogin.body?.code)), '', OPTIONS) + } + } finally { + const disabled = await context.request('PUT', `/api/platform/v1/iam/roles/${role.id}/status`, { status: 'DISABLED' }) + context.assert(MODULE, 'finally停用本轮无权限验收角色', disabled, + success(disabled) && disabled.body.data.status === 'DISABLED', `roleId=${id(role.id)}`, OPTIONS) + } +} diff --git a/scripts/admin-test-env-modules/vehicle-care.mjs b/scripts/admin-test-env-modules/vehicle-care.mjs new file mode 100644 index 000000000..fb336e83a --- /dev/null +++ b/scripts/admin-test-env-modules/vehicle-care.mjs @@ -0,0 +1,102 @@ +const BASE = '/api/platform/v1/last-leg/fleet/vehicles' +const VEHICLES = '/api/platform/v1/last-leg/master-data/vehicles' +const OPTIONS = { scenario: 'vehicle-care-lifecycle', deploymentBlocker: true } +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +/** Run only from an authorized fresh TEST lifecycle. Keep every created record and its evidence. */ +export async function verifyVehicleCare(context, module, vehicle, proof) { + const id = String(vehicle.id), base = `${BASE}/${encodeURIComponent(id)}/records` + proof.status = 'CHECKING'; proof.records = [] + function check(label, response, condition) { + context.assert(module, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(label) + } + async function command(method, path, body, label) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(module, label, response, '', OPTIONS) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || data == null) throw new Error(label) + return data + } + const occurredAt = new Date(Date.now() - 86400000).toISOString() + const maintenance = { recordType: 'MAINTENANCE', occurredAt, distanceUnit: 'KM', odometer: 80, + lastServiceOdometer: 20, nextServiceOdometer: 100, fuelQuantity: null, fuelUnit: null, verificationStatus: 'PENDING', remark: context.marker } + const createRequest = { idempotencyKey: `${context.marker}:CARE`, details: maintenance } + let record = await command('POST', base, createRequest, '建立本轮车辆保养记录') + proof.records.push({ recordNo: record.recordNo, createRequest }) + check('保养记录属于本车且原始单位不变', null, record.vehicleId === id && record.details?.distanceUnit === 'KM' && Number(record.details.odometer) === 80) + const replay = await command('POST', base, createRequest, '重放同一保养创建请求') + check('保养创建重放没有重复记录', null, replay.recordNo === record.recordNo && Number(replay.recordVersion) === Number(record.recordVersion)) + const originalVersion = Number(record.recordVersion) + const correction = { recordVersion: originalVersion, reason: '受控验收更正', details: { ...maintenance, odometer: 85 } } + record = await command('PUT', `${base}/${record.recordNo}`, correction, '更正本轮保养里程') + check('更正后业务值与版本均生效', null, Number(record.details.odometer) === 85 && Number(record.recordVersion) === originalVersion + 1) + const stale = await context.request('PUT', `${base}/${record.recordNo}`, correction) + check('旧版本不得覆盖运维记录', stale, stale?.http === 409 || (stale?.http === 200 && ['A0409', '409'].includes(String(stale.body?.code)))) + const history = await command('GET', `${base}/${record.recordNo}/history?page=1&size=20`, undefined, '读取保养更正历史') + check('保养历史保留旧里程与更正原因', null, history.records?.length === 2 && history.records[0].reason === correction.reason + && Number(history.records[1].snapshot?.details?.odometer) === 80) + const fuelRequest = { idempotencyKey: `${context.marker}:FUEL`, details: { ...maintenance, recordType: 'FUEL', distanceUnit: 'MI', + lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL' } } + let fuel = await command('POST', base, fuelRequest, '建立本轮加油记录') + proof.records.push({ recordNo: fuel.recordNo, createRequest: fuelRequest }) + const uploadPath = `${base}/${fuel.recordNo}/files?recordVersion=${fuel.recordVersion}&idempotencyKey=${encodeURIComponent(`${context.marker}:PHOTO`)}` + const upload = () => context.requestMultipart('POST', uploadPath, { name: 'controlled-meter.png', type: 'image/png', content: PNG }) + const first = await upload(); fuel = context.apiSuccess(module, '上传本轮受控运维照片', first, '', OPTIONS) + check('运维照片正式关联本记录', first, Boolean(fuel?.files?.length === 1 && fuel.files[0].fileId)) + const repeated = await upload(), repeatedFuel = context.apiSuccess(module, '同键重放运维照片', repeated, '', OPTIONS) + check('照片重放不增加附件或版本', repeated, repeatedFuel?.files?.length === 1 && repeatedFuel.files[0].fileId === fuel.files[0].fileId + && Number(repeatedFuel.recordVersion) === Number(fuel.recordVersion)) + proof.photoId = fuel.files[0].fileId + const photo = await context.requestBinary('GET', `${base}/${fuel.recordNo}/files/${encodeURIComponent(proof.photoId)}`) + check('运维图片下载真实字节一致', photo, photo?.http === 200 && String(photo.contentType).startsWith('image/png') + && photo.bytes instanceof Uint8Array && Buffer.from(photo.bytes).equals(PNG)) + for (const type of ['REPAIR', 'BREAKDOWN', 'RESCUE', 'EXCEPTION']) { + const priced = ['REPAIR', 'RESCUE'].includes(type) + const details = { recordType: type, occurredAt, distanceUnit: null, odometer: null, lastServiceOdometer: null, nextServiceOdometer: null, + fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker, + incident: { description: `${context.marker} 受控故障处置记录`, item: priced ? '更换轮胎' : null, place: priced ? '本轮测试作业点' : null, + handler: null, processingStatus: 'PENDING', exceptionType: type === 'EXCEPTION' ? 'VEHICLE_BREAKDOWN' : null, + reportedCost: priced ? 0 : null, currency: priced ? 'USD' : null } } + const initial = { idempotencyKey: `${context.marker}:${type}`, details } + let incident = await command('POST', base, initial, `创建本轮${type}记录`) + proof.records.push({ recordNo: incident.recordNo, createRequest: initial }) + check('事件项目地点和分类完整回显', null, incident.vehicleId === id && incident.details?.incident?.item === details.incident.item + && incident.details.incident.place === details.incident.place && incident.details.incident.exceptionType === details.incident.exceptionType) + incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成', + details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`) + check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED' + && (!priced || (Number(incident.details.incident.reportedCost) === 0 && incident.paymentStatus === 'UNLINKED'))) + const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`) + check('处理记录重新读取保持全部现场字段', null, refreshed.details.incident.item === details.incident.item + && refreshed.details.incident.place === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') + } + let other + try { + other = await command('POST', VEHICLES, { plateNumber: `CARE-${context.marker}`.slice(0, 64), model: 'Care isolation fixture', + fuelConsumptionPer100Km: 10, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '建立运维归属隔离测试车辆') + proof.otherVehicleId = String(other.id) + const foreignBase = `${BASE}/${encodeURIComponent(proof.otherVehicleId)}/records` + for (const [method, suffix, body] of [['GET', `/${record.recordNo}`, undefined], ['PUT', `/${record.recordNo}`, { ...correction, recordVersion: record.recordVersion }], + ['GET', `/${fuel.recordNo}/files/${encodeURIComponent(proof.photoId)}`, undefined]]) { + const response = await context.request(method, foreignBase + suffix, body) + check('其他车辆不能读取或修改本车记录和照片', response, response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(String(response.body?.code)))) + } + const foreign = await command('GET', `${foreignBase}?page=1&size=10`, undefined, '核对其他车辆记录列表') + check('其他车辆列表没有串入本车记录', null, Number(foreign.total) === 0 && foreign.records?.length === 0) + for (const type of ['MAINTENANCE', 'FUEL', 'REPAIR', 'BREAKDOWN', 'RESCUE', 'EXCEPTION']) { + const page = await command('GET', `${base}?recordType=${type}&page=1&size=10`, undefined, '按车辆和类型查询运维记录') + check('每类运维记录唯一且保留单位', null, Number(page.total) === 1 && page.records?.length === 1 && page.records[0].details.recordType === type + && page.records[0].vehicleId === id && (type !== 'FUEL' || page.records[0].details.fuelUnit === 'US_GAL')) + } + const after = await command('GET', `${VEHICLES}/${encodeURIComponent(id)}`, undefined, '回查车辆主档未被运维记录改写') + check('运维保存不改车辆里程状态或主档版本', null, Number(after.mileageKm) === Number(vehicle.mileageKm) + && after.status === vehicle.status && Number(after.recordVersion) === Number(vehicle.recordVersion)) + } finally { + if (other?.id) { + const ended = await command('PATCH', `${VEHICLES}/${encodeURIComponent(String(other.id))}/status`, { recordVersion: Number(other.recordVersion), status: 'INACTIVE' }, '停用本轮隔离用车辆并保留记录') + check('隔离用车辆已停用', null, ended.status === 'INACTIVE'); proof.otherVehicleStatus = ended.status + } + } + proof.status = 'VERIFIED' + return proof +} diff --git a/scripts/fba-core-regression-test-env.mjs b/scripts/fba-core-regression-test-env.mjs new file mode 100644 index 000000000..454f9abec --- /dev/null +++ b/scripts/fba-core-regression-test-env.mjs @@ -0,0 +1,152 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync } from 'node:fs' +import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { createTestEvidenceReaders, validateEvidenceConfig, EVIDENCE_ENV_KEYS } from './fba-core-regression-evidence.mjs' + +const repositoryRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..') +export const F40_MODES=Object.freeze(['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED']) +export const F40_TAX_MODES=Object.freeze(['BUY_DECLARATION','TAX_REFUND']) +export const REQUIRED_ENV_KEYS=Object.freeze(['CHONGCHONG_ADMIN_API_BASE_URL','CHONGCHONG_ADMIN_USERNAME', + 'CHONGCHONG_ADMIN_PASSWORD','ADMIN_PDA_USERNAME','ADMIN_PDA_PASSWORD','FBA_CORE_TEST_GATEWAY_ORIGIN',...EVIDENCE_ENV_KEYS]) + +export function parseCoreArguments(argv) { + const values={},flags=new Set() + for(let i=0;i1)throw Error('Choose plan or execute') + if(values['--environment'] && values['--environment']!=='TEST')throw Error('Only explicit TEST is supported') + const execute=flags.has('--execute'),runId=values['--run-id']||(!execute?'plan':null) + if(!runId||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(runId))throw Error('Fresh run-id is required') + if(execute&&values['--environment']!=='TEST')throw Error('Execution requires --environment TEST') + const taxMode=values['--tax-mode']||'BUY_DECLARATION' + if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode') + return {execute,environment:'TEST',runId,taxMode,outputDir:values['--output-dir']||`reports/fba-core-${runId}`} +} +function taxScope(taxMode) { + if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode') + return taxMode==='TAX_REFUND' + ? 'F40 TAX_REFUND: real customs upload, receipt confirmation, declaration/release, final documents, signatures and post-COMPLETED customer document reads/downloads.' + : 'F40 BUY_DECLARATION: existing buy-declaration shipment, signatures and remainder cancellation.' +} +export function executionPlan(config) { + return {environment:'TEST',execution:config.execute?'EXECUTE':'PLAN',runId:config.runId,outputDir:config.outputDir, + taxMode:config.taxMode,scope:taxScope(config.taxMode), + modes:F40_MODES,module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS, + optionalEnvironmentKeys:['CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'], + behavior:'Serial fresh fixtures; stop on first failed mode. HTTP creates customers/orders/files and uses shared PDA/pallet resources. SQL is read-only; no deploy or database mutation.', + reports:['manifest.json','summary.json',...F40_MODES.map(mode=>`${mode.toLowerCase()}.json`),'evidence/*.json'], + retention:'Exclusive run directory and evidence files. Interrupted lock requires operator review; no automatic cleanup or resume.', + scopeLimit:'F40 three modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'} +} +export function validateCoreEnvironment(env,root=repositoryRoot) { + if(basename(root)!=='baiyue-firstleg'||basename(dirname(root))!=='chongchong-global')throw Error('TEST workspace required') + if(REQUIRED_ENV_KEYS.some(key=>typeof env[key]!=='string'||!env[key].trim()))throw Error('Missing required TEST configuration') + let base,expected + try {base=new URL(env.CHONGCHONG_ADMIN_API_BASE_URL);expected=new URL(env.FBA_CORE_TEST_GATEWAY_ORIGIN)}catch{throw Error('Invalid TEST gateway configuration')} + if(!['http:','https:'].includes(base.protocol)||base.username||base.password||base.search||base.hash + ||expected.href!==expected.origin+'/'||base.origin!==expected.origin + ||/(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname))throw Error('TEST gateway origin mismatch') + if(Boolean(env.CHONGCHONG_MERCHANT_USERNAME)!==Boolean(env.CHONGCHONG_MERCHANT_PASSWORD))throw Error('Incomplete optional merchant credentials') + validateEvidenceConfig(env) + return base.origin +} +export function redactEvidence(value,secrets=[]) { + if(Array.isArray(value))return value.map(item=>redactEvidence(item,secrets)) + if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key, + /^(?:.*password|.*token|authorization|cookie|gateway|jenkinsUrl|privateKey)$/i.test(key)?'[REDACTED]':redactEvidence(item,secrets)])) + if(typeof value==='string')return secrets.filter(secret=>typeof secret==='string'&&secret.length>0) + .reduce((text,secret)=>text.split(secret).join('[REDACTED]'),value) + return value +} +export function modePassed(report) { + const proof=report?.createdResources?.phase5RootFulfillment + return report?.success===true&&report?.summary?.failed===0&&proof?.finalStatus==='VERIFIED' + && !(report.deploymentBlockers||[]).length && !(report.steps||[]).some(step=>step.passed===false) +} +function safeFailure(error) { + const message=typeof error?.message==='string'?error.message:'' + const mysql=/^MYSQL_([0-9]{3,5})$/.exec(message) + const http=/^TEST evidence HTTP ([1-5][0-9]{2})$/.exec(message) + return mysql?{code:'READ_ONLY_MYSQL_FAILED',mysqlError:Number(mysql[1])} + :http?{code:'READ_ONLY_HTTP_FAILED',http:Number(http[1])}:{code:'EXECUTION_FAILED'} +} +function createAcceptanceContext(mode,config,env,gateway) { + const options=validateExecutionOptions({...parseArguments(['--modules','phase5-root-fulfillment','--quiet','true']), + environment:'test',gateway,username:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD, + pdaUsername:env.ADMIN_PDA_USERNAME,pdaPassword:env.ADMIN_PDA_PASSWORD, + merchantUsername:env.CHONGCHONG_MERCHANT_USERNAME||'',merchantPassword:env.CHONGCHONG_MERCHANT_PASSWORD||'', + markerNamespace:createHash('sha256').update(config.runId+':'+mode).digest('hex').slice(0,12)}) + return new AcceptanceRun(options) +} +export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot}={}) { + const scopeDescription=taxScope(config.taxMode) + const gateway=validateCoreEnvironment(env) + const output=resolve(outputRoot,config.outputDir),reports=resolve(outputRoot,'reports'),rel=relative(reports,output) + if(!config.execute||config.environment!=='TEST'||!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Fresh report directory inside reports required') + mkdirSync(reports,{recursive:true}) + const lock=join(reports,'.fba-core-regression.lock') + const lockFd=openSync(lock,'wx') + const secrets=[...REQUIRED_ENV_KEYS,'CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'] + .filter(key=>/PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(key)).map(key=>env[key]) + const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription} + let initialized=false + const merchantBefore={username:process.env.MERCHANT_TEST_USERNAME,password:process.env.MERCHANT_TEST_PASSWORD} + try { + writeFileSync(lockFd,JSON.stringify({runId:config.runId,pid:process.pid,startedAt:summary.startedAt})) + mkdirSync(output,{recursive:false});initialized=true + mkdirSync(join(output,'evidence')) + const save=(name,value)=>writeFileSync(join(output,name),JSON.stringify(redactEvidence(value,secrets),null,2)+'\n',{flag:'wx'}) + save('manifest.json',executionPlan(config)) + readers ||= createTestEvidenceReaders(env) + await readers.preflight() + for(const mode of F40_MODES) { + const context=createContext(mode,config,env,gateway) + let sequence=0 + const evidence=async(kind,scope,read)=>{ + const record={mode,taxMode:config.taxMode,kind,scope,capturedAt:new Date().toISOString(),captured:false} + try {record.evidence=await read(scope);record.captured=true;return record.evidence} + catch (error) {record.error='READ_ONLY_EVIDENCE_FAILED';record.diagnostic=safeFailure(error);throw Error('Read-only evidence failed')} + finally {save(`evidence/${mode.toLowerCase()}-${String(++sequence).padStart(4,'0')}-${kind}.json`,record)} + } + context.context.f40SignatureOrder=mode + context.context.f40TaxMode=config.taxMode + context.context.f40ReadOnlyOutboxEvidence=scope=>evidence('completion',scope,readers.readCompletion) + context.context.phase4ReceivingEvidenceReader=scope=>evidence('receiving',scope,readers.readReceiving) + let report + try {report=await context.run()} + catch (error) {report={success:false,fatal:'FBA_CORE_MODE_EXCEPTION',diagnostic:safeFailure(error),summary:{failed:1},createdResources:context.createdResources}} + finally {secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password)} + report={...report,regressionScope:{taxMode:config.taxMode,signatureOrder:mode,description:scopeDescription}} + const name=`${mode.toLowerCase()}.json`;save(name,report) + const passed=modePassed(report) + summary.modes.push({mode,taxMode:config.taxMode,report:name,passed,summary:report.summary,evidenceFiles:sequence}) + if(!passed)break + } + summary.success=summary.modes.length===F40_MODES.length&&summary.modes.every(mode=>mode.passed) + } catch (error) {summary.failure='FBA_CORE_PREFLIGHT_OR_RUN_FAILED';summary.diagnostic=safeFailure(error)} + finally { + summary.finishedAt=new Date().toISOString() + try { + if(initialized)writeFileSync(join(output,'summary.json'),JSON.stringify(redactEvidence(summary,secrets),null,2)+'\n',{flag:'wx'}) + } finally { + closeSync(lockFd);unlinkSync(lock) + for(const [key,value] of [['MERCHANT_TEST_USERNAME',merchantBefore.username],['MERCHANT_TEST_PASSWORD',merchantBefore.password]]) { + if(value===undefined)delete process.env[key];else process.env[key]=value + } + } + } + return summary +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) { + try { + const config=parseCoreArguments(process.argv.slice(2)) + const result=config.execute?await runFbaCore(config):executionPlan(config) + console.log(JSON.stringify(result,null,2));if(config.execute&&!result.success)process.exitCode=1 + }catch {console.error('FBA core rejected configuration or existing run/lock; no automatic overwrite.');process.exitCode=1} +} diff --git a/scripts/fba-core-regression-test-env.test.mjs b/scripts/fba-core-regression-test-env.test.mjs new file mode 100644 index 000000000..e72680bc6 --- /dev/null +++ b/scripts/fba-core-regression-test-env.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { F40_MODES, parseCoreArguments, executionPlan, validateCoreEnvironment, redactEvidence, runFbaCore } from './fba-core-regression-test-env.mjs' + +const env=()=>({CHONGCHONG_ADMIN_API_BASE_URL:'https://test.example.invalid/api',FBA_CORE_TEST_GATEWAY_ORIGIN:'https://test.example.invalid', + CHONGCHONG_ADMIN_USERNAME:'offline-admin',CHONGCHONG_ADMIN_PASSWORD:'offline-password',ADMIN_PDA_USERNAME:'offline-pda', + ADMIN_PDA_PASSWORD:'offline-pda-password',JENKINS_URL:'https://ci.example.invalid/',JENKINS_USER:'offline-ci', + JENKINS_API_TOKEN:'offline-api-secret',FBA_CORE_JENKINS_AGENT:'java-test-agent'}) +const config=()=>parseCoreArguments(['--execute','--environment','TEST','--run-id','offline-check']) +const scope=()=>({customerId:'2105209721220861954',warehouseId:'193000000000200003',bookingId:'2105220190013755394', + stage:'BEFORE_COMPLETE',containerNo:'TEST-ONE',inboundCodes:['IN-1'],sourceInboundIds:['2105220155339444226'], + expectedPackagesByInbound:{'IN-1':1}}) +const completion=()=>({customerId:'90',rootOrderId:'100',sourceOrderIds:['100','101'],eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) +const root=()=>mkdtempSync(join(tmpdir(),'fba-core-contract-')) +const readers=()=>({preflight:async()=>{},readReceiving:async()=>({balances:[]}),readCompletion:async()=>({orders:[]})}) +const pass=()=>({success:true,summary:{failed:0},steps:[],deploymentBlockers:[],createdResources:{phase5RootFulfillment:{finalStatus:'VERIFIED'}}}) + +test('default plan is offline and exposes exact serial modes, required keys and retained reports',()=>{ + const plan=executionPlan(parseCoreArguments([])) + assert.equal(plan.execution,'PLAN');assert.deepEqual(plan.modes,F40_MODES) + assert.equal(plan.taxMode,'BUY_DECLARATION');assert.match(plan.scope,/BUY_DECLARATION/) + assert.ok(plan.requiredEnvironmentKeys.includes('ADMIN_PDA_PASSWORD')) + for(const args of [['--execute'],['--execute','--environment','PROD','--run-id','x'],['--plan','--execute'],['--mode','ROOT_FIRST']])assert.throws(()=>parseCoreArguments(args)) +}) +test('tax mode is explicit, defaults to BUY and rejects aliases, duplicate and unsupported values',()=>{ + const tax=parseCoreArguments(['--plan','--tax-mode','TAX_REFUND']) + assert.equal(tax.taxMode,'TAX_REFUND') + const plan=executionPlan(tax) + assert.deepEqual(plan.modes,F40_MODES);assert.match(plan.scope,/post-COMPLETED customer document reads\/downloads/) + for(const args of [['--tax-mode','REFUND'],['--tax-mode','tax_refund'],['--tax-mode',''],['--taxMode','TAX_REFUND'], + ['--tax-mode','TAX_REFUND','--tax-mode','BUY_DECLARATION']])assert.throws(()=>parseCoreArguments(args)) +}) +test('both tax modes pass unchanged to all three existing contexts and persist on all report surfaces',async()=>{ + for(const taxMode of ['BUY_DECLARATION','TAX_REFUND']){ + const outputRoot=root(),cfg={...config(),taxMode},seen=[] + const result=await runFbaCore(cfg,{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},async run(){ + seen.push({mode,taxMode:this.context.f40TaxMode}) + await this.context.phase4ReceivingEvidenceReader(scope()) + return pass() + }})}) + assert.equal(result.success,true);assert.equal(result.taxMode,taxMode) + assert.deepEqual(seen,F40_MODES.map(mode=>({mode,taxMode}))) + const read=name=>JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,name),'utf8')) + assert.equal(read('manifest.json').taxMode,taxMode) + assert.equal(read('summary.json').taxMode,taxMode) + assert.ok(read('summary.json').modes.every(mode=>mode.taxMode===taxMode)) + for(const mode of F40_MODES){ + const report=read(mode.toLowerCase()+'.json') + assert.equal(report.regressionScope.taxMode,taxMode) + assert.equal(report.regressionScope.signatureOrder,mode) + assert.match(report.regressionScope.description,new RegExp(taxMode)) + assert.equal(read(`evidence/${mode.toLowerCase()}-0001-receiving.json`).taxMode,taxMode) + } + } +}) +test('tax mode exception is retained with exact scope and does not start later fixtures',async()=>{ + const outputRoot=root(),cfg={...config(),taxMode:'TAX_REFUND'},seen=[] + const result=await runFbaCore(cfg,{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},createdResources:{},async run(){ + seen.push(mode);throw Error('private failure') + }})}) + assert.deepEqual(seen,['ROOT_FIRST']);assert.equal(result.success,false);assert.equal(result.taxMode,'TAX_REFUND') + const report=JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,'root_first.json'),'utf8')) + assert.equal(report.regressionScope.taxMode,'TAX_REFUND');assert.equal(report.fatal,'FBA_CORE_MODE_EXCEPTION') +}) +test('TEST configuration rejects cross environment origins, embedded credentials and wrong workspace',()=>{ + assert.equal(validateCoreEnvironment(env()),'https://test.example.invalid') + for(const changed of [{FBA_CORE_TEST_GATEWAY_ORIGIN:'https://other.example.invalid'}, + {CHONGCHONG_ADMIN_API_BASE_URL:'https://user:secret@test.example.invalid'}, + {CHONGCHONG_ADMIN_API_BASE_URL:'https://prod.example.invalid',FBA_CORE_TEST_GATEWAY_ORIGIN:'https://prod.example.invalid'}, + {ADMIN_PDA_PASSWORD:''},{FBA_CORE_JENKINS_AGENT:"test');println('bad"}])assert.throws(()=>validateCoreEnvironment({...env(),...changed})) + assert.throws(()=>validateCoreEnvironment(env(),'C:/wrong/chongchong-global-dev/baiyue-firstleg')) +}) +test('serial runner injects both readers and emits all modes without overlap',async()=>{ + const outputRoot=root(),order=[];let active=0 + const summary=await runFbaCore(config(),{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},async run(){ + assert.equal(active++,0);order.push(mode) + assert.equal(this.context.f40SignatureOrder,mode) + await this.context.phase4ReceivingEvidenceReader(scope());await this.context.f40ReadOnlyOutboxEvidence(completion()) + active--;return pass() + }})}) + assert.equal(summary.success,true);assert.deepEqual(order,F40_MODES) + const saved=JSON.parse(readFileSync(join(outputRoot,config().outputDir,'summary.json'),'utf8')) + assert.equal(saved.modes.length,3);assert.equal(saved.modes[0].evidenceFiles,2) +}) +test('first failed mode stops later fixtures and keeps report plus evidence',async()=>{ + const outputRoot=root(),calls=[] + const result=await runFbaCore(config(),{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},async run(){ + calls.push(mode);await this.context.phase4ReceivingEvidenceReader(scope());return {...pass(),success:false} + }})}) + assert.equal(result.success,false);assert.deepEqual(calls,['ROOT_FIRST']) + assert.ok(existsSync(join(outputRoot,config().outputDir,'root_first.json'))) + assert.ok(existsSync(join(outputRoot,config().outputDir,'evidence/root_first-0001-receiving.json'))) +}) +test('reader exception journals failure, hides secret and prevents next mode',async()=>{ + const outputRoot=root(),r=readers();r.readReceiving=async()=>{throw Error('offline-api-secret')} + const result=await runFbaCore(config(),{env:env(),outputRoot,readers:r,createContext:()=>({context:{},createdResources:{},async run(){ + await this.context.phase4ReceivingEvidenceReader(scope());return pass() + }})}) + assert.equal(result.modes.length,1);assert.equal(result.success,false) + const text=readFileSync(join(outputRoot,config().outputDir,'evidence/root_first-0001-receiving.json'),'utf8') + assert.match(text,/READ_ONLY_EVIDENCE_FAILED/);assert.doesNotMatch(text,/offline-api-secret/) +}) +test('preflight failure creates summary but no HTTP fixture; existing run or active lock cannot overwrite',async()=>{ + const outputRoot=root(),r=readers();let created=0;r.preflight=async()=>{throw Error('private')} + await runFbaCore(config(),{env:env(),outputRoot,readers:r,createContext:()=>{created++}}) + assert.equal(created,0) + const summaryPath=join(outputRoot,config().outputDir,'summary.json'),before=readFileSync(summaryPath,'utf8') + await runFbaCore(config(),{env:env(),outputRoot,readers:readers(),createContext:()=>{created++}}) + assert.equal(readFileSync(summaryPath,'utf8'),before);assert.equal(created,0) + const lock=join(outputRoot,'reports/.fba-core-regression.lock');writeFileSync(lock,'occupied') + await assert.rejects(runFbaCore({...config(),runId:'next',outputDir:'reports/next'},{env:env(),outputRoot,readers:readers()})) + assert.equal(readFileSync(lock,'utf8'),'occupied') +}) +test('reports redact dynamically created account secrets and environment/token values',()=>{ + const value=redactEvidence({password:'x',pdaToken:'y',message:'failure private-secret',nested:{gateway:'url',value:'safe'}},['private-secret']) + assert.equal(value.password,'[REDACTED]');assert.equal(value.pdaToken,'[REDACTED]') + assert.equal(value.message,'failure [REDACTED]');assert.equal(value.nested.value,'safe') +}) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs new file mode 100644 index 000000000..e530d42e9 --- /dev/null +++ b/scripts/fleet-test-env-acceptance.mjs @@ -0,0 +1,399 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs' +import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash, randomUUID } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { redactEvidence } from './fba-core-regression-test-env.mjs' +import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' +import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs' +import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' +import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const MODULE = 'fleet-specialized-acceptance' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const success = response => response?.http === 200 && id(response?.body?.code) === '200' +const key = () => `fleet-${randomUUID()}` + +export function parseFleetArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan' } + let action = null + const seen = new Set() + for (let index = 0; index < args.length; index++) { + const name = args[index] + if (['--plan', '--execute'].includes(name)) { + if (action) throw Error('Choose exactly one plan or execute action') + action = name; config.execute = name === '--execute'; continue + } + if (!['--environment', '--run-id', '--resume-report', '--resume-stage'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') + if (seen.has(name)) throw Error('Duplicate argument') + seen.add(name) + const value = args[++index] + if (name === '--environment') config.environment = value + else if (name === '--run-id') config.runId = value + else if (name === '--resume-report') config.resumeReport = value + else config.resumeStage = value + } + if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') + if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + return config +} +export function fleetExecutionPlan(config) { + return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, + outputDir: `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites', + resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, + stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', + 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', + 'atomic whole-trip departure', 'per-destination receipt and trip resource release'], + boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.', + limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' } +} + +export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { + const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] + const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) + if (stage === 'TERMINAL_READONLY') { + const failures = report?.steps?.filter(row => row.passed === false) + if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE' + || trip?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' + || proof.closure?.status !== trip.closure.status || trip.externalReceipt?.status !== 'VERIFIED' + || trip.externalDispatch?.status !== 'VERIFIED' || trip.reassignment?.status !== 'VERIFIED' + || !proof.receipts?.length || proof.finance?.status !== 'CONSUMER_PERSISTED' + || !failures?.length || failures.some(row => !['本次车次货物结案及车辆司机释放完成', '模块执行'].includes(row.name))) + throw Error('Terminal reconciliation requires completed original business proofs and only the known aggregate assertion failure') + return { resources, marker: proof.marker, terminal: true } + } + if (stage === 'EXTERNAL_PALLETIZED' || stage === 'EXTERNAL_RECEIPT_CONFIRMED') { + const confirmed = stage === 'EXTERNAL_RECEIPT_CONFIRMED' + const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL'), receipt = trip?.externalReceipt + if (report?.success !== false || proof?.stage !== 'EXTERNAL_RECEIPT' || proof.status !== 'CHECKING' + || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || trip.reservation?.status !== 'VERIFIED' || receipt?.status !== 'CHECKING' || trip.externalDispatch + || (confirmed ? (!receipt.receiptRequest?.idempotencyKey || !receipt.requests?.some(row => row.label === '运营按实际1托位和新单价30确认实收' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) : receipt.receiptRequest) + || !receipt.palletizeRequest?.idempotencyKey || !receipt.warehousePalletId || !proof.picker?.membershipId || !proof.pickerMarker + || !receipt.requests?.some(row => row.label === 'PDA正式完成外接收货及库存入账' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) + throw Error('Palletized checkpoint requires the exact successful PDA command and no subsequent receipt confirmation') + return { resources, marker: proof.marker, receiptReady: true, palletized: !confirmed, receiptConfirmed: confirmed } + } + if (stage === 'EXTERNAL_RECEIPT_READY') { + const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL') + if (report?.success !== false || proof?.stage !== 'RESERVATIONS' || proof.status !== 'CHECKING' + || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || trip.reservation?.status !== 'VERIFIED' || trip.externalReceipt || trip.externalDispatch || proof.picker + || !report.steps?.some(row => row.name === '创建海外PDA仓库操作员71' && row.code === 'A0409' && row.message === '手机号已存在')) + throw Error('Receipt-ready checkpoint requires verified coverage/reservations and an explicitly rejected operator creation with no receipt writes') + return { resources, marker: proof.marker, receiptReady: true } + } + if (report?.success !== false || proof?.stage !== 'FULL_COVERAGE' || proof.status !== 'CHECKING' || full?.mode !== 'FULL' + || full.marker !== proof.marker || full.dispatchNo !== trip?.dispatchNo || full.proof?.status !== 'CHECKING' + || !full.proof.requests?.length + || resources.fleetFeeCoverageCases.length !== 1 || !proof.originalDispatch || proof.originalDispatch.dispatchNo !== full.dispatchNo) + throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation') + const persisted = stage === 'FULL_COVERAGE_PERSISTED' + if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey + || !full.proof.requests.some(row => row.label === '正式保存本次原费用服务覆盖并转派送' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) + : (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo)) + throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation') + return { resources, marker: proof.marker, persisted } +} + +export async function verifyFleetTerminalState(context) { + const proof = context.createdResources.fleetAcceptance + const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const evidence = proof.terminalReadback = { status: 'CHECKING', requests: [], deliveries: [], dispatches: [] } + function check(label, passed, response) { + context.assert(MODULE, label, response, Boolean(passed), '', OPTIONS) + if (!passed) throw Error(label) + } + async function get(path) { + const result = await context.request('GET', path) + evidence.requests.push({ path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) + check('终态只读回查成功', success(result) && result.requestId && result.traceId, result) + return result.body.data + } + check('结案助手已完成货物与资源核对', trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + const current = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`) + check('当前车次已完成且两项资源结束事实与原证明一致', current.trip?.status === 'COMPLETED' + && current.trip.completedAt === trip.closure.finalTrip.completedAt + && id(current.trip.recordVersion) === id(trip.closure.finalTrip.recordVersion) + && JSON.stringify(current.trip.vehicleRelease) === JSON.stringify(trip.closure.finalTrip.vehicleRelease) + && JSON.stringify(current.trip.driverRelease) === JSON.stringify(trip.closure.finalTrip.driverRelease) + && current.trip.vehicleRelease && current.trip.driverRelease) + evidence.trip = current.trip + const expectedDispatches = [partial.dispatchNo, trip.externalDispatch.dispatchNo].sort() + check('终态车次仍关联全部原执行单', JSON.stringify(current.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify(expectedDispatches)) + for (const number of expectedDispatches) { + const dispatch = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) + check('原执行单已发车且拣货实发数量守恒', dispatch.dispatchNo === number && dispatch.status === 'DEPARTED' + && Number(dispatch.packageCount) === Number(dispatch.shippedPackageCount) + && Number(dispatch.pickedPackageCount) === Number(dispatch.shippedPackageCount)) + evidence.dispatches.push(dispatch) + } + check('整车最终箱数与原一次发车事实一致', evidence.dispatches.reduce((n, row) => n + Number(row.shippedPackageCount), 0) === Number(proof.departure.shippedPackages)) + const expectedDeliveries = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo].sort() + check('签收证明覆盖全部目的地派送', JSON.stringify(proof.receipts.map(row => row.deliveryNo).sort()) === JSON.stringify(expectedDeliveries)) + for (const number of expectedDeliveries) { + const delivery = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) + check('每张原派送保留完成及签收时间', delivery.order?.deliveryNo === number && delivery.order.status === 'DELIVERED' + && delivery.order.signedAt === proof.receipts.find(row => row.deliveryNo === number).signedAt) + evidence.deliveries.push(delivery.order) + } + for (const probe of trip.closure.probes) { + const detail = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(probe.tripNo)}`) + check('资源占用测试计划均已正式取消', detail.trip?.status === 'CANCELLED') + } + const pool = await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(proof.customerId)}&direction=RECEIVABLE`) + const fees = pool.filter(row => id(row.sourceId) === id(proof.finance.source.sourceFeeId) && Number(row.sourceVersion) === Number(proof.finance.source.sourceVersion)) + check('原外接应收仍唯一且金额正确', fees.length === 1 && fees[0].amount === 30 && fees[0].currency === 'CNY' + && id(fees[0].customerId) === id(proof.customerId) && fees[0].sourceType === 'OVERSEAS_DELIVERY_FEE') + evidence.fees = fees; evidence.status = 'VERIFIED' + proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; proof.stage = 'FINISHED' + return proof +} + +export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { + if (terminalReadOnly) return verifyFleetTerminalState(context) + const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance + : context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } + function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) } + function check(label, value, response) { context.assert(MODULE, label, response, Boolean(value), '', OPTIONS); if (!value) throw Error(label) } + async function request(method, path, body, label, token) { + const response = await context.request(method, path, body, token) + check(label, success(response), response); return response.body.data + } + const source = context.createdResources.phase4OverseasDispatch, receiving = context.createdResources.phase4OverseasPda + check('专项只承接本轮实际已收货及预排资源', source?.marker === context.marker && source.dispatchNo + && receiving?.marker === context.marker && receiving.finalStatus === 'COMPLETED' && receiving.customerId && receiving.overseasWarehouseId) + const customerId = id(receiving.customerId), warehouseId = id(receiving.overseasWarehouseId) + proof.customerId = customerId; proof.warehouseId = warehouseId + const dispatchBase = '/api/platform/v1/last-leg/dispatches' + let trip, partial + if (resumeReceiptReady) { + trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const current = await request('GET', `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`, undefined, '恢复实收前核对原车次分配与客户执行单') + const dispatch = await request('GET', `${dispatchBase}/${encodeURIComponent(partial.dispatchNo)}`, undefined, '恢复实收前核对原排车尚未拣货或发车') + check('仍是原已核对车辆司机与未执行客户排车', current.trip?.status === 'PLANNED' + && id(current.trip.vehicleId) === id(trip.vehicleId) && id(current.trip.driverId) === id(trip.driverId) + && current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === partial.dispatchNo + && id(current.dispatches[0].customerId) === customerId && dispatch.dispatchNo === partial.dispatchNo + && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + } else { + const original = await request('GET', `${dispatchBase}/${encodeURIComponent(source.dispatchNo)}`, undefined, '读取本轮原始预排实物') + if (resumeFull) check('原样本恢复前排车货物未变且未开始作业', JSON.stringify(original.items) === JSON.stringify(proof.originalDispatch.items) + && Number(original.pickedPackageCount) === 0 && Number(original.shippedPackageCount) === 0) + proof.originalDispatch = original + trip = resumeFull ? context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + : await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId) + proof.tripNo = trip.tripNo + const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` + function transferBody(vehicle) { return { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), + deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', + vehiclePlate: vehicle.vehiclePlate, driverName: vehicle.driverName, driverPhone: vehicle.driverPhone, appointmentAccount: 'SELF', + remark: `${context.marker} 原费用覆盖专用验证`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖货物派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } } + let detail = await request('GET', tripPath, undefined, '读取当前派车事实供转派送使用') + check('当前车次资源及原客户排车关联仍一致', id(detail.trip.vehicleId) === id(trip.vehicleId) + && id(detail.trip.driverId) === id(trip.driverId) && detail.dispatches.some(row => row.dispatchNo === source.dispatchNo && id(row.customerId) === customerId)) + if (!resumeFull) context.createdResources.fleetFeeCoverageCases = [] + const full = resumeFull ? context.createdResources.fleetFeeCoverageCases[0] + : { marker: context.marker, mode: 'FULL', dispatchNo: source.dispatchNo, customerId, transferBody: transferBody(detail.trip) } + if (resumeFull && !resumePersisted) { + full.previousReadOnlyAttempts ||= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof + } else if (!resumeFull) context.createdResources.fleetFeeCoverageCases.push(full) + stage('FULL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted }) + stage('CANCEL_FULL_AND_REARRANGE') + detail = await request('GET', tripPath, undefined, '读取取消未执行全覆盖用例前车次版本') + await request('POST', `${tripPath}/dispatches/unbind`, { recordVersion: detail.trip.recordVersion, dispatchNo: full.dispatchNo, + reason: '全覆盖用例验证完毕,正式取消未执行发运并以新排车验证部分覆盖' }, '先解除未执行全覆盖排车关联') + await request('POST', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(full.proof.outboundNo)}/cancel`, + { reason: '本轮全覆盖验证完毕,正式取消未执行出库后重新安排' }, '取消未执行全覆盖出库') + await request('DELETE', `${dispatchBase}/${encodeURIComponent(full.dispatchNo)}`, undefined, '正式取消原排车释放其托盘锁') + const draftToken = key(), planPalletIds = original.items.map(row => id(row.planPalletId)) + check('原排车保留可追溯计划托盘', planPalletIds.length > 0 && planPalletIds.every(value => /^[1-9]\d*$/.test(value))) + proof.rearrange = { originalDispatchNo: full.dispatchNo, draftToken, planPalletIds } + const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 }, '建立同批当前库存的新排车草稿') + for (const planPalletId of planPalletIds) await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId, draftToken, draftContainerId: id(container.id) }, '锁定已正式释放的本轮托盘') + const arranged = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds, draftToken }, '以新排车重新安排本轮同批货物') + check('取消重排产生新排车且货量不变', arranged.dispatchNo && arranged.dispatchNo !== full.dispatchNo + && Number(arranged.packageCount) === Number(original.packageCount)) + proof.rearrange.dispatchNo = arranged.dispatchNo + detail = await request('GET', tripPath, undefined, '读取原车次当前可关联版本') + detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, dispatchNo: arranged.dispatchNo, + reason: '本轮取消后新排车重新关联原计划车次' }, '将新排车正式关联原车次') + trip.replacedDispatchNo = trip.dispatchNo; trip.dispatchNo = arranged.dispatchNo + partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: arranged.dispatchNo, customerId, transferBody: transferBody(detail.trip) } + context.createdResources.fleetFeeCoverageCases.push(partial) + stage('PARTIAL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, partial) + stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip, controlledPalletMeasurements) + } + const access = await resolvePickerAccess(context) + let picker = null + try { + if (resumePalletized || resumeReceiptConfirmed) { + const suffix = proof.pickerMarker.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() + const username = `pda_overseas_${suffix.toLowerCase()}_71` + check('仅复用本轮创建的临时PDA身份', proof.picker.username === username && access?.role?.id) + const users = await request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '200', keyword: username })}`, undefined, '核对原临时PDA账号绑定及角色') + const user = users?.records?.find(row => id(row.membershipId) === id(proof.picker.membershipId) && row.username === username) + check('原PDA仍仅有本仓作业员权限且不是受保护账号', user && id(user.tenantId) === '0' && !user.protectedAccount + && id(user.overseasWarehouseId) === warehouseId && user.roleIds?.length === 1 && id(user.roleIds[0]) === id(access.role.id)) + picker = { membershipId: user.membershipId, username, token: null } + const active = await request('PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, { status: 'ACTIVE' }, '为核对原PDA完成请求恢复本轮临时作业员') + check('原临时PDA已恢复启用', active?.status === 'ACTIVE') + const loggedIn = await request('POST', '/api/auth/login', { loginId: username, password: `Pda${suffix}A719` }, '以原生成凭据登录本轮临时PDA', '') + picker.token = loggedIn?.accessToken + } else { + const pickerContext = Object.create(context) + proof.pickerMarker ||= `${context.marker}-FPDA-${randomUUID().slice(0, 8)}` + pickerContext.marker = proof.pickerMarker + picker = access ? await createPicker(pickerContext, 71, warehouseId, access) : null + } + check('专项使用本轮绑定起运仓的PDA账号', picker?.token && picker.membershipId) + proof.picker = { membershipId: picker.membershipId, username: picker.username } + stage('EXTERNAL_RECEIPT'); await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, { reconcilePalletized: resumePalletized, reconcileConfirmed: resumeReceiptConfirmed }) + stage('EXTERNAL_DISPATCH'); await verifyFleetExternalDispatch(context, MODULE, trip) + const external = trip.externalDispatch + stage('FINANCE_CONSUMER') + const sources = await request('GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId, businessType: 'DELIVERY_ORDER', businessNo: external.deliveryNo })}`, + undefined, '读取外接应收稳定来源编号和版本') + const target = sources?.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE') + check('外接应收来源只属于本次客户派送及实收', target?.length === 1 && target[0].businessNo === external.deliveryNo + && id(target[0].customerId) === customerId && Number(target[0].originalAmount) === 30 && target[0].originalCurrency === 'CNY') + let matched = [] + for (let attempt = 0; attempt < 30; attempt++) { + const pool = await request('GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`, undefined, '等待Finance消费本次外接费用') + check('Finance费用池为当前客户应收列表', Array.isArray(pool) && pool.every(row => id(row.customerId) === customerId && row.direction === 'RECEIVABLE')) + matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && Number(row.sourceVersion) === Number(target[0].sourceVersion)) + if (matched.length) break + await wait(1000) + } + check('Finance真实消费且仅一笔30CNY外接应收', matched.length === 1 && matched[0].sourceType === 'OVERSEAS_DELIVERY_FEE' + && Number(matched[0].amount) === 30 && matched[0].currency === 'CNY') + proof.finance = { status: 'CONSUMER_PERSISTED', source: target[0], pool: matched, producerAck: 'NOT_VERIFIED' } + external.financeAcknowledgement = 'CONSUMER_PERSISTED_PRODUCER_ACK_NOT_VERIFIED' + stage('PICK_EACH_DISPATCH') + const dispatchNos = [partial.dispatchNo, external.dispatchNo] + proof.dispatchNos = dispatchNos + const dispatchDetails = [] + for (const [index, dispatchNo] of dispatchNos.entries()) { + const pickingContext = Object.create(context); pickingContext.marker = `${context.marker}-FLEET-PICK-${index}` + check('逐客户正式PDA拣货及完成成功', await pickAll(pickingContext, picker.token, dispatchNo)) + dispatchDetails.push(await request('GET', `${dispatchBase}/${encodeURIComponent(dispatchNo)}`, undefined, '回查逐单拣货数量及未发车事实')) + } + check('每张客户排车实际拣足但尚未发车', dispatchDetails.every(row => row.status === 'READY_TO_DEPART' + && Number(row.pickedPackageCount) === Number(row.packageCount) && Number(row.shippedPackageCount) === 0)) + stage('WHOLE_TRIP_DEPARTURE') + const recordVersion = await confirmFleetLoading(context, MODULE, trip) + proof.departureRequest = { recordVersion, idempotencyKey: key(), remark: '本轮自有货与真实外接货统一发车' } + const departPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(partial.dispatchNo)}/depart` + const departed = await request('POST', departPath, proof.departureRequest, 'PDA一次命令发出全车两张客户执行单', picker.token) + check('整车发车包含所有排车且箱数守恒', departed?.tripNo === trip.tripNo && departed.status === 'IN_TRANSIT' + && JSON.stringify(departed.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify([...dispatchNos].sort()) + && Number(departed.shippedPackages) === dispatchDetails.reduce((n, row) => n + Number(row.packageCount), 0)) + const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) + check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) + proof.departure = departed + stage('RECEIPTS_AND_RESOURCE_RELEASE') + proof.receipts = [] + for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { + const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, + undefined, '签收前读取本次实际派送') + const completed = await saveReceiptAndComplete(context, delivery.order, customerId, index + 71) + check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) + proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) + } + proof.closure = await finishFleetTrip(context, MODULE, trip) + check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') + } finally { await disablePicker(context, picker, 71) } + return proof +} + +export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, root = ROOT } = {}) { + if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test' + || basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash + || /(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname)) throw Error('Invalid TEST gateway') + for (const name of ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) if (!env[name]) throw Error(`Missing ${name}`) + const output = resolve(root, `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid') + mkdirSync(resolve(root, 'reports'), { recursive: true }) + const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') + let context, initialized = false, report + const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(name)).map(([,value]) => value) + const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, [...secrets, + context?.token, context?.pdaToken, context?.refreshToken, context?.context?.merchantQualificationFixture?.password]), null, 2) + '\n') + try { + writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId, startedAt: new Date().toISOString() })) + mkdirSync(output, { recursive: false }); initialized = true; mkdirSync(join(output, 'evidence')) + save('manifest.json', fleetExecutionPlan(config)) + let resumed + if (config.resumeReport) { + const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous) + if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports') + const bytes = readFileSync(previous) + resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage) + save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage }) + } else { + readers ||= createTestEvidenceReaders(env) + await readers.preflight() + } + const options = validateExecutionOptions({ ...parseArguments(['--modules', 'phase4-overseas-dispatch', '--quiet', 'true']), + gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + pdaUsername: env.ADMIN_PDA_USERNAME, pdaPassword: env.ADMIN_PDA_PASSWORD, merchantUsername: '', merchantPassword: '', + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) + context = createContext ? createContext(options) : new AcceptanceRun(options) + let evidenceNumber = 0 + context.context.phase4ReceivingEvidenceReader = async scope => { + const evidence = await readers.readReceiving(scope) + save(`evidence/receiving-${++evidenceNumber}.json`, { scope, evidence }); return evidence + } + const executeModule = context.executeModule.bind(context) + context.executeModule = async (name, handler) => { + await executeModule(name, handler) + save('progress.json', { stage: name, steps: context.steps, modules: context.modules, createdResources: context.createdResources }) + if (context.steps.some(step => step.passed === false) || context.modules.at(-1)?.success === false) throw Error(`Producer stopped: ${name}`) + if (name === 'phase4-overseas-dispatch') { + await executeModule(MODULE, () => scenario(context)) + if (context.modules.at(-1)?.success === false) throw Error('Fleet case stopped at original fixture') + } + } + if (resumed) { + context.createdResources = resumed.resources; context.marker = resumed.marker + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' + await context.executeModule(MODULE, () => scenario(context, { resumeFull: !resumed.receiptReady, + resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) + const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) + report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, + createdResources: context.createdResources, resumedFrom: config.resumeReport } + } else report = await context.run() + report.fleetExecution = fleetExecutionPlan(config) + // Keep the unresolved producer ACK explicit; a green consumer row cannot close the complete fleet goal. + report.fleetGoalComplete = false + } catch (error) { + report = { success: false, fatal: error instanceof Error ? error.message : 'FLEET_ACCEPTANCE_FAILED', + summary: context ? buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) : { failed: 1 }, + steps: context?.steps || [], modules: context?.modules || [], createdResources: context?.createdResources || {} } + } finally { + if (initialized) save('report.json', report || { success: false, fatal: 'RUN_INTERRUPTED' }) + closeSync(lock); unlinkSync(lockPath) + } + return { success: report?.success === true, report: join(output, 'report.json'), summary: report?.summary, + fleet: report?.createdResources?.fleetAcceptance?.status, fatal: report?.fatal } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { const config = parseFleetArguments(process.argv.slice(2)); const result = config.execute ? await runFleetAcceptance(config) : fleetExecutionPlan(config) + console.log(JSON.stringify(result, null, 2)); if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error instanceof Error ? error.message : 'Fleet acceptance failed'); process.exitCode = 1 } +} diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs new file mode 100644 index 000000000..0760b7040 --- /dev/null +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -0,0 +1,184 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, resolve } from 'node:path' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState } from './fleet-test-env-acceptance.mjs' + +test('fleet entry defaults to a no-side-effect plan and requires explicit TEST and unique run ID', () => { + const p = parseFleetArguments([]) + assert.equal(p.execute, false); assert.equal(fleetExecutionPlan(p).execution, 'PLAN') + for (const args of [['--execute'], ['--execute', '--run-id', 'new'], ['--execute', '--environment', 'prod', '--run-id', 'new'], + ['--execute', '--environment', 'TEST', '--run-id', '../old'], ['--plan', '--execute'], ['--force'], ['--run-id', 'a', '--run-id', 'b']]) { + assert.throws(() => parseFleetArguments(args)) + } + assert.equal(parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'new']).execute, true) +}) + +async function executeFixture(failProducer = false) { + const temporary = mkdtempSync(join(tmpdir(), 'fleet-entry-contract-')) + const root = join(temporary, 'chongchong-global', 'baiyue-firstleg'); mkdirSync(root, { recursive: true }) + const calls = [], secrets = 'never-print-this-secret' + const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid', CHONGCHONG_ADMIN_USERNAME: 'fixture-admin', + CHONGCHONG_ADMIN_PASSWORD: secrets, ADMIN_PDA_USERNAME: 'fixture-pda', ADMIN_PDA_PASSWORD: secrets } + const config = parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'fixture']) + const createContext = options => ({ options, context: {}, createdResources: {}, steps: [], modules: [], coverageGaps: [], token: secrets, + async executeModule(name, handler) { + try { await handler(); this.modules.push({ name, success: true }) } + catch { this.modules.push({ name, success: false }); this.steps.push({ name: 'producer failed', passed: false }) } + }, + async run() { + await this.executeModule('producer', async () => { calls.push('producer'); if (failProducer) throw Error('fixture failure') }) + await this.executeModule('phase4-overseas-dispatch', async () => { calls.push('dispatch'); this.createdResources.current = { accessToken: secrets } }) + return { success: true, steps: this.steps, modules: this.modules, createdResources: this.createdResources, summary: { failed: 0 } } + }, + }) + try { + const options = { root, env, createContext, readers: { async preflight() { calls.push('preflight') }, async readReceiving() { throw Error('not used') } }, + scenario: async context => { calls.push('fleet'); context.createdResources.fleetAcceptance = { status: 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP' } } } + const result = await runFleetAcceptance(config, options) + const report = JSON.parse(readFileSync(result.report, 'utf8')) + assert.ok(!readFileSync(result.report, 'utf8').includes(secrets)) + assert.equal(existsSync(join(root, 'reports/.fleet-acceptance.lock')), false) + const oldReport = readFileSync(result.report, 'utf8') + const repeated = await runFleetAcceptance(config, options) + assert.equal(repeated.success, false) + assert.equal(readFileSync(result.report, 'utf8'), oldReport, 'existing run is never overwritten or resumed') + return { result, report, calls } + } finally { + assert.equal(dirname(resolve(temporary)), resolve(tmpdir())) + assert.ok(resolve(temporary).startsWith(join(resolve(tmpdir()), 'fleet-entry-contract-'))) + rmSync(temporary, { recursive: true, force: true }) + } +} + +test('fresh entry runs dedicated fleet scenario after prerequisites without altering the default F40 chain', async () => { + const { result, report, calls } = await executeFixture() + assert.equal(result.success, true) + assert.deepEqual(calls, ['preflight', 'producer', 'dispatch', 'fleet']) + assert.equal(report.fleetGoalComplete, false) + assert.equal(report.createdResources.current.accessToken, '[REDACTED]') +}) + +test('failed prerequisite stops before further fixture creation and retains the failed report', async () => { + const { result, report, calls } = await executeFixture(true) + assert.equal(result.success, false) + assert.deepEqual(calls, ['preflight', 'producer']) + assert.equal(report.fatal, 'Producer stopped: producer') +}) + +test('environment mismatch rejects before invoking any readers or contexts', async () => { + let touched = false + await assert.rejects(() => runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'new' }, { + env: { CHONGCHONG_ENV: 'production' }, readers: { preflight() { touched = true } }, + }), /TEST/) + assert.equal(touched, false) +}) + +test('reviewed resume requires explicit paired checkpoint options', () => { + assert.throws(() => parseFleetArguments(['--resume-report', 'reports/old/report.json'])) + assert.throws(() => parseFleetArguments(['--resume-report', 'reports/old/report.json', '--resume-stage', 'PICK_EACH_DISPATCH'])) + const c = parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'reviewed', '--resume-report', 'reports/old/report.json', '--resume-stage', 'FULL_COVERAGE']) + assert.equal(c.resumeStage, 'FULL_COVERAGE') +}) + +test('checkpoint guard forbids repeating commands whose write outcome may be unknown', () => { + const report = { success: false, createdResources: { + fleetAcceptance: { marker: 'M', stage: 'FULL_COVERAGE', status: 'CHECKING', tripNo: 'T', originalDispatch: { dispatchNo: 'D' } }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D' }], + fleetFeeCoverageCases: [{ mode: 'FULL', marker: 'M', dispatchNo: 'D', proof: { status: 'CHECKING', requests: [{ method: 'GET' }] } }], + } } + assert.equal(reviewedFullCheckpoint(report).marker, 'M') + const mutated = structuredClone(report) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ method: 'POST' }) + assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.pop() + mutated.createdResources.fleetFeeCoverageCases[0].proof.outboundNo = 'OUT-1' + assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) + assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) + Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { idempotencyKey: 'original-key' } }) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ label: '正式保存本次原费用服务覆盖并转派送', method: 'POST', http: 200, code: '200' }) + assert.equal(reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED').persisted, true) +}) + +test('receipt-ready reconciliation cannot repeat a receipt that has already started', () => { + const report = { success: false, steps: [{ name: '创建海外PDA仓库操作员71', code: 'A0409', message: '手机号已存在' }], createdResources: { + fleetAcceptance: { marker: 'M', stage: 'RESERVATIONS', status: 'CHECKING', tripNo: 'T' }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + } } + assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_READY').receiptReady, true) + report.createdResources.fleetTrips[0].externalReceipt = { status: 'CHECKING' } + assert.throws(() => reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_READY'), /no receipt writes/) +}) + +test('palletized checkpoint requires a saved successful command, owned operator, and no later confirmation', () => { + const report = { success: false, createdResources: { + fleetAcceptance: { marker: 'M', stage: 'EXTERNAL_RECEIPT', status: 'CHECKING', tripNo: 'T', picker: { membershipId: '71' }, pickerMarker: 'M-PDA' }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' }, externalReceipt: { status: 'CHECKING', + warehousePalletId: 'P1', palletizeRequest: { idempotencyKey: 'original' }, requests: [ + { label: 'PDA正式完成外接收货及库存入账', method: 'POST', http: 200, code: '200' }, + ] } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + } } + assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_PALLETIZED').palletized, true) + assert.throws(() => reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_CONFIRMED'), /exact successful PDA command/) + const confirmed = structuredClone(report) + confirmed.createdResources.fleetTrips[0].externalReceipt.receiptRequest = { idempotencyKey: 'confirm-original' } + assert.throws(() => reviewedFullCheckpoint(confirmed, 'EXTERNAL_RECEIPT_CONFIRMED'), /exact successful PDA command/) + confirmed.createdResources.fleetTrips[0].externalReceipt.requests.push({ label: '运营按实际1托位和新单价30确认实收', method: 'POST', http: 200, code: '200' }) + assert.equal(reviewedFullCheckpoint(confirmed, 'EXTERNAL_RECEIPT_CONFIRMED').receiptConfirmed, true) + assert.throws(() => reviewedFullCheckpoint(confirmed, 'EXTERNAL_PALLETIZED'), /exact successful PDA command/) + for (const mutate of [ + r => { r.fleetTrips[0].externalReceipt.requests[0].code = 'A0409' }, + r => { delete r.fleetTrips[0].externalReceipt.palletizeRequest.idempotencyKey }, + r => { delete r.fleetAcceptance.picker }, + r => { r.fleetTrips[0].externalReceipt.receiptRequest = { idempotencyKey: 'later' } }, + r => { r.fleetTrips[0].externalDispatch = { status: 'CHECKING' } }, + ]) { + const invalid = structuredClone(report); mutate(invalid.createdResources) + assert.throws(() => reviewedFullCheckpoint(invalid, 'EXTERNAL_PALLETIZED'), /exact successful PDA command/) + } +}) + +function terminalFixture(fault = '') { + const finalTrip = { status: 'COMPLETED', completedAt: '2026-10-02T22:01:00Z', recordVersion: '30', vehicleRelease: { endLocation: 'ONT8' }, driverRelease: { endLocation: 'ONT8' } } + const closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', finalTrip, probes: [{ tripNo: 'PROBE' }] } + const trip = { tripNo: 'T', closure, externalReceipt: { status: 'VERIFIED' }, reassignment: { status: 'VERIFIED' }, externalDispatch: { status: 'VERIFIED', dispatchNo: 'EXT', deliveryNo: 'DEL-EXT' } } + const proof = { marker: 'M', tripNo: 'T', customerId: '92', stage: 'RECEIPTS_AND_RESOURCE_RELEASE', closure, + receipts: ['DEL-OWN', 'DEL-EXT'].map(deliveryNo => ({ deliveryNo, signedAt: '2026-10-02T22:00:00' })), + departure: { shippedPackages: 6 }, finance: { status: 'CONSUMER_PERSISTED', source: { sourceFeeId: 'F1', sourceVersion: 1 } } } + const resources = { fleetAcceptance: proof, fleetTrips: [trip], fleetFeeCoverageCases: [{ mode: 'PARTIAL', dispatchNo: 'OWN', proof: { deliveryNos: ['DEL-OWN'] } }] } + const calls = [] + const context = { createdResources: resources, assert() {}, async request(method, path) { + calls.push({ method, path }); assert.equal(method, 'GET') + let data + if (path.endsWith('/trips/T')) data = { trip: finalTrip, dispatches: ['OWN', 'EXT'].map(dispatchNo => ({ dispatchNo })) } + else if (path.endsWith('/trips/PROBE')) data = { trip: { status: 'CANCELLED' } } + else if (path.includes('/dispatches/')) { const number = path.split('/').at(-1), count = number === 'OWN' ? 5 : 1 + data = { dispatchNo: number, status: 'DEPARTED', packageCount: count, pickedPackageCount: count, shippedPackageCount: fault === 'quantity' ? 0 : count } } + else if (path.includes('/delivery-orders/')) data = { order: { deliveryNo: path.split('/').at(-1).split('?')[0], status: fault === 'unsigned' ? 'WAIT_DELIVERY' : 'DELIVERED', signedAt: '2026-10-02T22:00:00' } } + else if (path.includes('/fee-pool?')) data = [{ customerId: '92', sourceId: 'F1', sourceVersion: fault === 'wrong-fee-version' ? 2 : 1, amount: 30, currency: 'CNY', sourceType: 'OVERSEAS_DELIVERY_FEE' }] + else throw Error('unexpected path') + return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'r', traceId: 't' } + } } + return { context, calls, report: { success: false, createdResources: resources, steps: [{ name: '本次车次货物结案及车辆司机释放完成', passed: false }] } } +} + +test('terminal reconciliation reads actual completion, every dispatch/delivery and exact fee without replaying writes', async () => { + const f = terminalFixture() + assert.equal(reviewedFullCheckpoint(f.report, 'TERMINAL_READONLY').terminal, true) + const result = await verifyFleetTerminalState(f.context) + assert.equal(result.terminalReadback.status, 'VERIFIED') + assert.equal(f.calls.length, 7) + assert.ok(f.calls.every(row => row.method === 'GET')) + const invalid = terminalFixture().report + invalid.steps.push({ name: '真实业务失败', passed: false }) + assert.throws(() => reviewedFullCheckpoint(invalid, 'TERMINAL_READONLY'), /known aggregate assertion/) +}) + +for (const fault of ['quantity', 'unsigned', 'wrong-fee-version']) test(`terminal readback rejects ${fault}`, async () => { + const f = terminalFixture(fault) + await assert.rejects(() => verifyFleetTerminalState(f.context)) + assert.equal(f.context.createdResources.fleetAcceptance.terminalReadback.status, 'CHECKING') +}) diff --git a/scripts/http-script-environment-contract.test.mjs b/scripts/http-script-environment-contract.test.mjs index 2e534e6ad..8caabd465 100644 --- a/scripts/http-script-environment-contract.test.mjs +++ b/scripts/http-script-environment-contract.test.mjs @@ -38,12 +38,13 @@ test('active scripts no longer carry the retired variables, fixed Gateway, or so assert.doesNotMatch(pendingOrders, /PASSWORD:\s*'[^']+'/) }) -test('documentation names both external environment files and requires explicit selection', () => { +test('TEST documentation requires explicit selection and keeps development configuration in its own instructions', () => { for (const source of [readme, agents]) { - assert.match(source, /chongchong-global-dev-acceptance\.env/) assert.match(source, /chongchong-global-test-acceptance\.env/) assert.match(source, /--env-file/) } + assert.match(agents, /chongchong-global-dev-acceptance\.env/) assert.match(readme, /不会自行查找或加载上述文件/) assert.match(readme, /ADMIN_OVERSEAS_GATEWAY/) + assert.match(readme, /移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`/) }) diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs index 9f5a48cf4..383edbff1 100644 --- a/scripts/mixed-container-outbound-test-env.mjs +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' // Explicit retained fixture continuation, not a new fixture generator. const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2) @@ -114,9 +115,14 @@ try { if (!picker?.token) throw new Error('PDA operator unavailable') result.pickerMembershipId = picker.membershipId save() + const tripTask = await api('GET', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}`, undefined, picker.token) + if (!tripTask.tripNo) throw new Error('Retained dispatch must be assigned to a qualified fleet trip before picking; prepare through the current fleet API') + const trip = await api('GET', `${base}/fleet/trips/${encodeURIComponent(tripTask.tripNo)}`) if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') + const tripVersion = await confirmFleetLoading(context, 'mixed-container-outbound', { tripNo: tripTask.tripNo, stops: trip.trip.stops }) result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, - { idempotencyKey: `${context.marker}-DEPART`, remark: `${context.marker}-test-only` }, picker.token) + { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: `${context.marker}-test-only` }, picker.token) + if (result.departure.status !== 'IN_TRANSIT' || !result.departure.dispatches?.some(item => item.dispatchNo === result.dispatchNo)) throw new Error('Whole-trip departure result missing retained dispatch') save() } const deliveries = result.transfer.deliveries diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index fa7bd677b..b23f8a9c1 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -1,4 +1,5 @@ import { readFile, writeFile } from 'node:fs/promises' +import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' @@ -50,14 +51,7 @@ const pdaToken = directPdaToken || pdaLogin.overseasAccessToken let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) let alreadyDeparted = task.status === 'DEPARTED' - || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) -if (!alreadyDeparted && task.status === 'PENDING') { - await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', route: `${marker}-ROUTE`, - estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), - }) -} +assert(alreadyDeparted || task.tripNo, 'Prepare and bind a qualified fleet trip before running retained dispatch fulfillment') const existingPage = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) @@ -78,7 +72,6 @@ const transfer = existingDeliveries.length > 0 ? { idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', - driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`, appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], }) @@ -93,14 +86,6 @@ const expectedPdaItems = scenario.partialRelabel ? 1 : 2 assert(Array.isArray(task.items) && task.items.length === expectedPdaItems, `Expected ${expectedPdaItems} PDA pallet items`) if (!alreadyDeparted) { - if (task.items.every(item => Number(item.pickedPackages) === 0)) { - await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', fbaAppointmentNo: `UAT-${marker.slice(-8)}`, - fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'CONFIRMED', - estimatedArrivalAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), - }) - } for (const [itemIndex, item] of task.items.entries()) { let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) for (const [positionIndex, position] of item.positions.entries()) { @@ -118,7 +103,6 @@ if (!alreadyDeparted) { } task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) alreadyDeparted = task.status === 'DEPARTED' - || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) } if (!alreadyDeparted && task.status !== 'COMPLETED') { const pickingCompleted = await api(overseas, pdaToken, 'POST', @@ -127,12 +111,21 @@ if (!alreadyDeparted && task.status !== 'COMPLETED') { task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) } assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') -const departed = alreadyDeparted ? { - status: 'DEPARTED', - shippedPackages: task.items.reduce((total, item) => total + Number(item.pickedPackages), 0), -} : await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) -assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') +if (!alreadyDeparted) { + const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(task.tripNo)}` + const trip = await api(overseas, platformToken, 'GET', tripPath) + const loading = await api(overseas, platformToken, 'GET', `${tripPath}/loading`) + assert(!loading.blockReason, `Trip not ready: ${loading.blockReason}`) + const confirmed = await api(overseas, platformToken, 'POST', `${tripPath}/loading`, { + recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, + pallets: controlledPalletMeasurements(loading.cargo, trip.trip.stops), reason: '受控测试测量,每实托100kg/1m³/1托位', + }) + const departed = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, + { recordVersion: confirmed.recordVersion, idempotencyKey: key('DEPART').slice(0,128), remark: marker }) + assert(departed.status === 'IN_TRANSIT' && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), 'Whole-trip departure missing dispatch') +} +const departedDispatch = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`) +assert(departedDispatch.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') const outbound = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) @@ -176,9 +169,9 @@ const visit = (visits.records || []).find((value) => value.deliveryNo === delive assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, - dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, + dispatchNo, dispatchStatus: departedDispatch.status, outboundNo: transfer.outboundNo, outboundStatus: outbound.outbound.status, deliveryNo, - deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departed.shippedPackages, + deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departedDispatch.shippedPackageCount, passed: true, finishedAt: new Date().toISOString() } await writeFile(output, JSON.stringify(report, null, 2), 'utf8') console.log(JSON.stringify(report, null, 2)) -- 2.54.0 From 4e129a7f4a74399a4a500594a5dd9a9b5f9eabc6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 07:25:37 +0800 Subject: [PATCH 078/176] test(fleet): read complete customer inventory snapshots --- scripts/README.md | 2 + scripts/fba-core-regression-evidence.mjs | 2 + scripts/fleet-inventory-evidence.mjs | 53 +++++++++++++++++++++++ scripts/fleet-inventory-evidence.test.mjs | 32 ++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 scripts/fleet-inventory-evidence.mjs create mode 100644 scripts/fleet-inventory-evidence.test.mjs diff --git a/scripts/README.md b/scripts/README.md index a1fe8d6ad..3381a9d9b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -375,6 +375,8 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 `fba-core-regression-evidence.mjs` 是 Admin/F40 共用的内部只读取证器,不是独立 CLI。只在明确的 TEST Jenkins 节点读取 `firstleg-test-order`、`firstleg-test-lastleg` 对应数据库的一致性只读快照,原始库存、位置、流水及事件身份严格按本轮客户/仓库/柜次过滤。连接凭据只在执行节点内存中处理,不执行 SQL 写入;调用者保存独立报告,失败不能用空结果代替。直接合同为 `fba-core-regression-evidence.test.mjs`,覆盖只读 SQL 边界、完整作用域、长 ID、零事件保留与敏感错误脱敏;离线合同不代替真实数据库取证。 +`fleet-inventory-evidence.mjs` 是上述取证器的内部库存查询模块,通过 `createTestEvidenceReaders(...).readFleetInventory({ marker, customerId, warehouseId })` 调用,无独立命令、环境加载或写入能力。对明确客户和仓库读取全部原始余额、位置与所有类型流水,包含零数量、版本和时间;同一只读事务的逐表计数用于拒绝遗漏、截断和重复行,不使用非零汇总代替。每个字段保留为字符串或空值,避免长 ID 精度丢失。调用者保存报告及本轮来源证明,不能用历史客户的探针替代新场景验收。合同为 `fleet-inventory-evidence.test.mjs`。 + 海外卸柜反向故障验收从 Admin 总入口显式传 `--modules phase4-overseas-pda --verify-unload-event-recovery true`,按正式依赖准备新客户、新柜及实收,首个本轮柜完成前设置独立故障 hook;普通收货默认不启用。目标事件固定为 `OverseasContainerUnloaded`,生产者为 Lastleg,消费者为 Firstleg,事件平台作用域固定 0、库存仍按本轮实际客户隔离。按原事件验证 ACK 丢失、DEAD、人工 retry 与重复确认;在恢复服务配置后,用相同只读库存取证器采集 `AFTER_EVENT_RECOVERY`,核对原始库存、位置、托盘组成、收货完整键及流水零漂移。任一方向恢复失败保留任务恢复记录并判失败;不得与独立部署或共享环境造数并行,不修改业务 SQL。报告沿用显式新 `--output`,旧失败报告和业务样本保留,不自动清理。此项不覆盖签收完成回写、设备和车辆新版链路。 Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-executor.mjs` 和 `test-event-fault-runtime.py`,不是额外 CLI 入口。它们仅在显式 `CHONGCHONG_ENV=test` 和已授权环境中运行:在首个本轮计划完成前启动已有 ACK 丢失代理,短暂重建同镜像 Firstleg 容器,保留数据卷、端口和网络身份,只变更事件目标与本次故障退避参数。仅选定的装柜事件被抑制 ACK;其他事件直通。核对 12 次失败、DEAD、受保护原事件 retry、DUPLICATE 及业务订单零新增,结束或失败都恢复原镜像配置并检查健康,随后停止本任务代理。不修改业务 SQL、载荷、事件 ID 或 hash,不重放已发布事件。恢复失败必须保留阻断和任务元数据;独立后端发布或其他环境造数不得与此窗口并行。凭据仅在执行节点内存与权限600的临时恢复文件中使用,恢复完成后移除临时文件中的凭据。离线合同为 `test-event-fault-executor.test.mjs`。 diff --git a/scripts/fba-core-regression-evidence.mjs b/scripts/fba-core-regression-evidence.mjs index d0457d08c..e6fa56c5d 100644 --- a/scripts/fba-core-regression-evidence.mjs +++ b/scripts/fba-core-regression-evidence.mjs @@ -1,4 +1,5 @@ // Internal explicit TEST evidence queries; no network or environment reads on import. +import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs' const stages = new Set(['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION', 'AFTER_EVENT_RECOVERY']) const numeric = new Set(['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages', 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages', 'packageCount']) const fields = { @@ -173,6 +174,7 @@ export function createTestEvidenceReaders(env, fetcher = globalThis.fetch) { return result.rows } return { + readFleetInventory: async scope => fleetInventoryFromRows(scope, await execute('lastleg', buildFleetInventorySql(scope))), readReceiving: createReceivingEvidenceReader(sql => execute('lastleg',sql)), readCompletion: async scope => completionEvidenceFromRows(scope,await execute('order',buildCompletionEvidenceSql(scope))), preflight: async () => { diff --git a/scripts/fleet-inventory-evidence.mjs b/scripts/fleet-inventory-evidence.mjs new file mode 100644 index 000000000..4c4bc2741 --- /dev/null +++ b/scripts/fleet-inventory-evidence.mjs @@ -0,0 +1,53 @@ +// Internal read-only snapshot for two-customer fleet acceptance. Import has no side effects. +const collections = { + balances: { table: 'll_inventory_balance', fields: 'id customerId customerName overseasWarehouseId overseasWarehouseCode overseasWarehouseName destinationWarehouseId destinationWarehouseCode destinationWarehouseName inboundOrderId inboundOrderNo inboundCode skuCode productNameZh productNameEn availablePackages holdPackages allocatedPackages pickedPackages inTransitPackages receivingPackages procurementPackages version createdAt updatedAt' }, + positions: { table: 'll_inventory_position', fields: 'id customerId balanceId overseasWarehouseId destinationWarehouseCode palletId palletNo batchNo warehouseLocationId warehouseLocationCode availablePackages holdPackages allocatedPackages pickedPackages receivingPackages status version createdAt updatedAt' }, + ledger: { table: 'll_inventory_ledger', fields: 'id customerId customerName balanceId positionId overseasWarehouseId destinationWarehouseCode inboundOrderId palletId palletNo batchNo warehouseLocationCode skuCode cartonMark cartonPackages businessType businessOrderId businessOrderNo inboundCode inboundOrderNo outboundOrderNo sourceEventId idempotencyKey statusFrom statusTo beforePackages deltaPackages afterPackages operatorId remark occurredAt createdAt' }, +} +const snake = value => value.replace(/[A-Z]/g, letter => '_' + letter.toLowerCase()) +function validate(scope) { + if (!scope || !/^[A-Za-z0-9_-]{1,100}$/.test(scope.marker || '') + || ['customerId', 'warehouseId'].some(key => typeof scope[key] !== 'string' || !/^[1-9][0-9]{0,19}$/.test(scope[key]))) { + throw Error('Invalid fleet inventory scope') + } +} +const literal = value => `CONVERT(0x${Buffer.from(value, 'utf8').toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` + +export function buildFleetInventorySql(scope) { + validate(scope) + const where = `BINARY customer_id=BINARY (${literal(scope.customerId)}) AND BINARY overseas_warehouse_id=BINARY (${literal(scope.warehouseId)})` + const queries = [] + for (const [collection, { table, fields }] of Object.entries(collections)) { + const json = fields.split(' ').map(field => `'${field}',CAST(${snake(field)} AS CHAR)`).join(',') + queries.push(`SELECT JSON_OBJECT('collection','${collection}','row',JSON_OBJECT(${json})) FROM ${table} WHERE ${where} ORDER BY id;`) + queries.push(`SELECT JSON_OBJECT('collection','${collection}','count',CAST(COUNT(*) AS CHAR)) FROM ${table} WHERE ${where};`) + } + return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', ...queries, 'COMMIT;'].join('\n') +} + +export function fleetInventoryFromRows(scope, rows) { + validate(scope) + if (!Array.isArray(rows)) throw Error('Missing fleet inventory snapshot') + const result = { scope: { marker: scope.marker, customerId: scope.customerId, warehouseId: scope.warehouseId, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [], positions: [], ledger: [] } + const counts = new Map() + for (const entry of rows) { + const definition = collections[entry?.collection] + if (!definition) throw Error('Unexpected fleet inventory collection') + if (Object.hasOwn(entry, 'count')) { + if (counts.has(entry.collection) || entry.row || !/^(0|[1-9][0-9]*)$/.test(String(entry.count))) throw Error('Invalid fleet inventory count') + counts.set(entry.collection, BigInt(entry.count)); continue + } + const row = entry.row + if (!row || definition.fields.split(' ').some(field => !Object.hasOwn(row, field)) + || typeof row.id !== 'string' || !/^[1-9][0-9]*$/.test(row.id) + || row.customerId !== scope.customerId || row.overseasWarehouseId !== scope.warehouseId) throw Error('Incomplete or foreign fleet inventory row') + result[entry.collection].push(row) + } + for (const name of Object.keys(collections)) { + if (counts.get(name) !== BigInt(result[name].length) || new Set(result[name].map(row => row.id)).size !== result[name].length) { + throw Error('Truncated or duplicated fleet inventory snapshot') + } + } + return result +} diff --git a/scripts/fleet-inventory-evidence.test.mjs b/scripts/fleet-inventory-evidence.test.mjs new file mode 100644 index 000000000..59722f667 --- /dev/null +++ b/scripts/fleet-inventory-evidence.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs' +import { assertReadOnlySql, createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' + +const scope = { marker: 'CROSS-TEST-1', customerId: '2106128532165988353', warehouseId: '193000000000200003' } +const empty = () => ['balances', 'positions', 'ledger'].map(collection => ({ collection, count: '0' })) +test('customer/warehouse snapshot includes zero rows and all ledger kinds in a single read-only transaction', () => { + const sql = assertReadOnlySql(buildFleetInventorySql(scope)) + assert.equal((sql.match(/SELECT JSON_OBJECT/g) || []).length, 6) + assert.doesNotMatch(sql, /\bLIMIT\b|available_packages\s*>|business_type\s*=/) + assert.match(sql, /'deltaPackages',CAST\(delta_packages AS CHAR\)/) + assert.match(sql, /'version',CAST\(version AS CHAR\)/) + assert.equal((sql.match(/BINARY customer_id=/g) || []).length, 6) + assert.equal((sql.match(/BINARY overseas_warehouse_id=/g) || []).length, 6) + assert.equal(fleetInventoryFromRows(scope, empty()).scope.coverage, 'CUSTOMER_WAREHOUSE_COMPLETE') +}) +test('rejects missing count, truncation, duplicate count, foreign or incomplete rows', () => { + for (const rows of [[], empty().slice(1), [...empty(), empty()[0]], [...empty(), { collection: 'balances', row: { id: '1', customerId: 'wrong' } }], [{ collection: 'balances', count: '1' }, ...empty().slice(1)]]) { + assert.throws(() => fleetInventoryFromRows(scope, rows)) + } + for (const changed of [{ customerId: '1 OR 1=1' }, { warehouseId: 193000000000200003 }, { marker: "';DROP" }]) assert.throws(() => buildFleetInventorySql({ ...scope, ...changed })) +}) +test('transport integration uses the existing secret-safe TEST reader', async () => { + let requests = 0 + const reader = createTestEvidenceReaders({ JENKINS_URL: 'https://ci.example.invalid/', JENKINS_USER: 'test', JENKINS_API_TOKEN: 'secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' }, async (_url, options) => { + requests++; assert.equal(options.method, 'POST') + return { ok: true, text: async () => 'FBA_CORE_JSON=' + JSON.stringify({ ok: true, rows: empty() }) } + }) + assert.deepEqual((await reader.readFleetInventory(scope)).balances, []) + assert.equal(requests, 1) +}) -- 2.54.0 From 18d22418ca93c787cc712346aa4b5dab5094fbc0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 07:42:18 +0800 Subject: [PATCH 079/176] test(fleet): cover separate customers sharing one physical trip --- scripts/README.md | 2 + scripts/admin-test-env-modules/README.md | 4 + ...ross-customer-acceptance-contract.test.mjs | 99 +++++++ .../fleet-cross-customer-acceptance.mjs | 193 +++++++++++++ ...t-cross-customer-fixture-contract.test.mjs | 211 ++++++++++++++ .../fleet-cross-customer-fixture.mjs | 267 ++++++++++++++++++ scripts/fleet-cross-customer-test-env.mjs | 143 ++++++++++ .../fleet-cross-customer-test-env.test.mjs | 88 ++++++ 8 files changed, 1007 insertions(+) create mode 100644 scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs create mode 100644 scripts/fleet-cross-customer-test-env.mjs create mode 100644 scripts/fleet-cross-customer-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 3381a9d9b..7c19839e0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`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`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 ## 验收证据校验 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 9950d8bab..4d610f410 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,9 @@ # Admin 测试环境 HTTP 模块 +`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 + +`fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁证据与事务中途故障回滚严格区分,后者保持 `NOT_TESTED`。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 + `TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 `EXTERNAL_RECEIPT_CONFIRMED` 仅接受原运营实收确认明确成功、后续只读/重放断言失败且尚未开始排车关联的样本。先核对原来货已可排车、实际容量及单一库存流水,再原键原载荷重放实收确认;不重新入库。重放比较以数据库持久化后的完整预订为基准,避免直接拿命令返回的纳秒 `updatedAt` 与数据库毫秒精度比较;货量、报价、版本及全部业务字段仍精确核对。实收总箱数字段为正式 `receivedPackages`。 diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs new file mode 100644 index 000000000..0ac813278 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { verifyFleetCrossCustomer, fixtureContract } from './fleet-cross-customer-acceptance.mjs' + +function model(fault='') { + const copy=value=>structuredClone(value),calls=[],keys=new Set() + const members=[0,1].map(i=>({marker:'RUN',customerId:`C${i}`,incomingNo:`IN${i}`,dispatchNo:`DS${i}`,outboundNo:`OUT${i}`,deliveryNo:`DL${i}`, + balanceId:`B${i}`,positionId:`P${i}`,palletId:`PAL${i}`,palletNo:`PALLET${i}`,receiptVerified:true})) + const fixture={marker:'RUN',stage:fixtureContract.stage,tripNo:'TRIP',warehouseId:'WH',pdaToken:'not-a-real-token',members} + const trip={trip:{tripNo:'TRIP',warehouseId:'WH',status:'PLANNED',recordVersion:'0',vehicleId:'V',driverId:'D',stops:[{name:'Origin'}, + {name:'A',destinationKey:'FBA:A'},{name:'B',destinationKey:'FBA:B'}]},dispatches:members.map(m=>({dispatchNo:m.dispatchNo,customerId:m.customerId,review:{status:'PENDING'}}))} + const state=members.map(m=>({dispatch:{dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'PENDING_DEPARTURE',packageCount:1,pickedPackageCount:0, + items:[{id:`ITEM${m.customerId}`,warehousePalletId:m.palletId}]},delivery:{order:{deliveryNo:m.deliveryNo,customerId:m.customerId,dispatchNo:m.dispatchNo,outboundNo:m.outboundNo,status:'WAIT_DELIVERY',version:'0'}}, + outbound:{outboundNo:m.outboundNo,dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'WAIT_OUTBOUND'}, + stock:{scope:{marker:'RUN',customerId:m.customerId,warehouseId:'WH',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'}, + balances:[{id:m.balanceId,customerId:m.customerId,overseasWarehouseId:'WH',availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}], + positions:[{id:m.positionId,customerId:m.customerId,overseasWarehouseId:'WH',balanceId:m.balanceId,palletId:m.palletId,palletNo:m.palletNo,availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}],ledger:[]}})) + const cargo=members.map((m,i)=>({dispatchNo:m.dispatchNo,customerId:m.customerId,warehousePalletId:m.palletId,palletNo:m.palletNo,destinationKey:i?'FBA:B':'FBA:A',packages:1})) + const response=data=>({http:200,body:{code:'200',data:copy(data)},requestId:'request',traceId:'trace'}) + const context={marker:'RUN',token:'admin',createdResources:{},assert(_m,label,_r,condition){assert.ok(condition,label)}, + async request(method,raw,body,token){ + calls.push({method,raw,body:copy(body),token});const path=new URL(raw,'https://offline.invalid').pathname + let index=members.findIndex(m=>path.includes(m.dispatchNo)||path.includes(m.outboundNo)||path.includes(m.deliveryNo)) + const row=state[index],member=members[index] + if(path.endsWith('/review')){trip.dispatches[index].review.status='APPROVED';trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)} + if(path.endsWith('/loading-review')){trip.loadingReview={status:'APPROVED'};trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)} + if(path.endsWith('/loading')){if(method==='POST')trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response({recordVersion:trip.trip.recordVersion,manifestHash:'HASH',cargo,blockReason:null})} + if(path.endsWith('/TRIP'))return response(trip) + if(path.endsWith('/pick')){ + if(keys.has(body.idempotencyKey))return response({replayed:true}) + keys.add(body.idempotencyKey);row.dispatch.pickedPackageCount=1 + row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0' + row.stock.ledger=['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:member.customerId,overseasWarehouseId:'WH',positionId:member.positionId,businessOrderNo:member.dispatchNo,businessType:type,deltaPackages:type==='SHIP'?'-1':'1'})) + if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger[2],id:'EXTRA'}) + return response({replayed:false}) + } + if(path.endsWith('/complete')){row.dispatch.status='READY_TO_DEPART';return response({status:'READY_TO_DEPART'})} + if(path.endsWith('/depart')){ + if(state.some(s=>s.dispatch.pickedPackageCount!==1)){ + if(fault==='gate reorders route') [trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] + if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} + return {http:fault==='outage disguised as rejection'?503:200,body:{code:fault==='outage disguised as rejection'?'S0503':'A0409',message:fault==='unrelated gate rejection'?'车次版本已变化':'DS1:请完成拣货并核平全部箱数'},requestId:'r',traceId:'t'} + } + const replayed=keys.has(body.idempotencyKey);keys.add(body.idempotencyKey);trip.trip.status='IN_TRANSIT' + for(const [i,s] of state.entries())if(!(fault==='partial departure'&&i===1)){s.dispatch.status='DEPARTED';s.outbound.status='OUTBOUNDED'} + if(fault==='depart deducts again'){row.stock.balances[0].availablePackages='-1';row.stock.positions[0].availablePackages='-1'} + return response({tripNo:'TRIP',status:'IN_TRANSIT',shippedPackages:'2',dispatches:members.map(m=>({dispatchNo:m.dispatchNo})),replayed}) + } + if(path.includes('/api/pda/'))return response({dispatchNo:member.dispatchNo,warehouseId:'WH',tripNo:'TRIP',status:row.dispatch.pickedPackageCount?'COMPLETED':'PDA_PENDING', + items:[{dispatchItemId:`ITEM${member.customerId}`,palletNo:member.palletNo,scheduledPackages:1,pickedPackages:String(row.dispatch.pickedPackageCount), + positions:fault==='missing PDA position'?[]:[{id:member.positionId,balanceId:member.balanceId,availablePackages:row.stock.positions[0].availablePackages}]}]}) + if(path.includes('/dispatches/'))return response(row.dispatch) + if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]}) + if(path.endsWith('/status')){row.delivery.order.status='DELIVERING';row.delivery.order.version=String(Number(row.delivery.order.version)+1);return response({deliveryStatus:'DELIVERING'})} + if(path.endsWith('/documents')){ + if(method==='GET')return response({version:row.delivery.order.version,categories:[]}) + row.delivery.order.status='DELIVERED';row.delivery.order.version=String(Number(row.delivery.order.version)+1) + if(fault==='signs both')state[1-index].delivery.order.status='DELIVERED' + return response({version:row.delivery.order.version}) + } + if(path.includes('/delivery-orders/'))return response(row.delivery) + throw new Error(`Unexpected ${method} ${path}`) + },async requestMultipart(method,path,body){calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})}} + const readInventory=async scope=>{const value=copy(state[members.findIndex(m=>m.customerId===scope.customerId)].stock);if(fault==='wrong stock scope')value.positions[0].customerId='OTHER';return value} + return {context,fixture,readInventory,calls,state} +} + +test('default call and import do not request, create or activate anything',async()=>{ + let called=false;const result=await verifyFleetCrossCustomer({request(){called=true}},{}) + assert.equal(result,fixtureContract);assert.equal(called,false) +}) +test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{ + const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}) + assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED') + assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,3) + assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED']) + assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken)) + assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken)) +}) +for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both']) { + test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{ + const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}), + fault==='gate reorders route'?/Rejected departure leaves both complete inventories\/outbounds\/deliveries\/trip unchanged/:undefined) + assert.equal(m.context.createdResources.fleetCrossCustomer.status,'FAILED') + assert.equal(m.context.createdResources.fleetCrossCustomer.midTransactionRollback,'NOT_TESTED') + }) +} +test('missing reader, reused customer and stale marker reject before any API request',async()=>{ + for(const change of ['reader','customer','marker']){ + const m=model();if(change==='customer')m.fixture.members[1].customerId=m.fixture.members[0].customerId;if(change==='marker')m.fixture.marker='OLD' + await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:change==='reader'?undefined:m.readInventory}));assert.equal(m.calls.length,0) + } +}) +test('module contains no autonomous runner or SQL/environment/credential plumbing',async()=>{ + const source=await readFile(new URL('./fleet-cross-customer-acceptance.mjs',import.meta.url),'utf8') + assert.doesNotMatch(source,/process\.env|fetch\(|execSync|INSERT INTO|UPDATE ll_|createPicker|createMerchant/) + assert.match(source,/controlledPalletMeasurements/) +}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs new file mode 100644 index 000000000..999aacb69 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -0,0 +1,193 @@ +import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs' + +const MODULE = 'fleet-cross-customer-acceptance' +const ROOT = '/api/platform/v1/last-leg' +const PDA = '/api/pda/v1/last-leg/dispatches' +const OPTIONS = { scenario: 'fleet-cross-customer-departure', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const ok = response => response?.http === 200 && id(response.body?.code) === '200' +const encode = encodeURIComponent +const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` +const canonical = value => JSON.stringify(normalize(value)) +function normalize(value) { + // Route stops and other business arrays are ordered. Raw readers return stable ID order. + if (Array.isArray(value)) return value.map(normalize) + return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])) : value +} +function integer(value) { + if (!['number','string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer') + return Number(value) +} +const buckets = ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'] +const balanceBuckets = [...buckets,'inTransitPackages','procurementPackages'] + +export const fixtureContract = Object.freeze({ + stage: 'TWO_FRESH_RECEIVED_DISPATCHES_BOUND_AND_TRANSFERRED_NOT_PICKED', + fixture: 'marker, warehouseId, tripNo, pdaToken, members[2]: {marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified:true}', + preparation: 'Two independently qualified current-run customers; each formally received one physical pallet/box in the same warehouse; draft lock/pre-arrange separately; bind both to one assigned qualified vehicle/driver trip; transfer each to its own outbound and delivery. No picking yet.', + reader: 'readInventory(scope) => {scope:{marker,customerId,warehouseId,coverage:"CUSTOMER_WAREHOUSE_COMPLETE"}, balances:all raw rows including zero, positions:all raw rows including zero, ledger:all immutable movement rows}. Retain camelCase IDs and every quantity bucket; never substitute a grouped public SKU page.', + helperLimit: 'Existing external-receipt helper selects plan.cargo[0].customerId and requires an existing own-cargo capacity plan; external-dispatch helper has one mutable externalReceipt slot. Do not run either twice pretending to create two customers.', + boundary: 'This exercises a prerequisite gate rejection, not a mid-transaction rollback. No fault injection, account activation, SQL writes, deployment, cleanup deletion or implicit fixture creation.', +}) + +/** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution. + * Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys. + */ +export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory } = {}) { + if (!execute) return fixtureContract + if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage + || !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' + || fixture.members?.length !== 2 || new Set(fixture.members.map(row=>row.customerId)).size !== 2 + || fixture.members.some(row=>row.marker!==context.marker || row.receiptVerified!==true + || ['customerId','incomingNo','dispatchNo','outboundNo','deliveryNo','balanceId','positionId','palletId','palletNo'].some(key=>!id(row[key])))) { + throw new Error('Requires two distinct current-run customers, immutable receipt references and a complete raw inventory reader') + } + for (const key of ['dispatchNo','outboundNo','deliveryNo','positionId','palletId']) { + if (new Set(fixture.members.map(row=>id(row[key]))).size!==2) throw new Error(`Cross-customer fixture reuses ${key}`) + } + context.createdResources ||= {} + if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly') + const proof = context.createdResources.fleetCrossCustomer = { marker:context.marker,tripNo:fixture.tripNo, + warehouseId:fixture.warehouseId,members:fixture.members.map(({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})=>({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})), + status:'CHECKING',requests:[],snapshots:{},gateProof:'NOT_RUN',midTransactionRollback:'NOT_TESTED',cleanup:'Caller owns account/session lifecycle; retain all business fixtures' } + const check = (name, passed) => { + context.assert(MODULE,name,null,Boolean(passed),'',OPTIONS) + if (!passed) throw new Error(name) + } + const key = suffix => `${context.marker}-CROSS-${suffix}` + const tripPath = `${ROOT}/fleet/trips/${encode(fixture.tripNo)}` + const request = async (method,path,body,label,pda=false,rejection=false) => { + const response = await context.request(method,path,body,pda?fixture.pdaToken:context.token,{}) + proof.requests.push({method,path,body,label,http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId}) + const denied = response?.http===409 || (response?.http===200 && id(response.body?.code)==='A0409') + check(label,Boolean(response?.requestId && response?.traceId) && (rejection?denied && rejection(id(response.body?.message)):ok(response))) + return response.body?.data + } + const stock = async member => { + const scope={marker:context.marker,customerId:member.customerId,warehouseId:fixture.warehouseId} + const raw=await readInventory(scope) + check('Inventory reader proves complete customer/warehouse scope',canonical(raw?.scope)===canonical({...scope,coverage:'CUSTOMER_WAREHOUSE_COMPLETE'})) + for(const collection of ['balances','positions','ledger'])check(`Complete ${collection} rows have unique identities and customer ownership`,Array.isArray(raw[collection]) + && new Set(raw[collection].map(row=>id(row.id))).size===raw[collection].length + && raw[collection].every(row=>id(row.id) && id(row.customerId)===id(member.customerId) && id(row.overseasWarehouseId)===id(fixture.warehouseId))) + check('Original receipt balance and physical position are retained even at zero',raw.balances.some(row=>id(row.id)===id(member.balanceId)) + && raw.positions.some(row=>id(row.id)===id(member.positionId) && id(row.balanceId)===id(member.balanceId) && id(row.palletId)===id(member.palletId) && row.palletNo===member.palletNo)) + for(const balance of raw.balances) { + for(const field of balanceBuckets) integer(balance[field]) + const positions=raw.positions.filter(row=>id(row.balanceId)===id(balance.id)) + for(const field of buckets) check(`Raw balance and every position conserve ${field}`,integer(balance[field])===positions.reduce((sum,row)=>sum+integer(row[field]),0)) + } + check('No orphan stock positions',raw.positions.every(row=>raw.balances.some(balance=>id(balance.id)===id(row.balanceId)))) + return raw + } + const snapshot = async label => { + const trip=await request('GET',tripPath,undefined,'Read shared trip') + check('Trip members are exactly the two independent customers',trip.trip?.tripNo===fixture.tripNo && id(trip.trip.warehouseId)===id(fixture.warehouseId) + && trip.dispatches?.length===2 && fixture.members.every(member=>trip.dispatches.some(row=>row.dispatchNo===member.dispatchNo && id(row.customerId)===id(member.customerId)))) + const members=[] + for(const member of fixture.members) { + const dispatch=await request('GET',`${ROOT}/dispatches/${encode(member.dispatchNo)}`,undefined,'Read customer dispatch') + const outbound=await request('GET',scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`,member.customerId),undefined,'Read customer outbound') + const delivery=await request('GET',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId),undefined,'Read customer delivery') + const task=await request('GET',`${PDA}/${encode(member.dispatchNo)}`,undefined,'Read PDA task',true) + check('Execution records retain exact customer, warehouse and dispatch ownership',dispatch.dispatchNo===member.dispatchNo + && id(dispatch.overseasWarehouseId)===id(fixture.warehouseId) && dispatch.items?.length===1 + && id(dispatch.items[0].warehousePalletId)===id(member.palletId) + && outbound.outbound?.outboundNo===member.outboundNo && outbound.outbound.dispatchNo===member.dispatchNo + && id(outbound.outbound.overseasWarehouseId)===id(fixture.warehouseId) && outbound.deliveries?.length===1 + && outbound.deliveries[0].deliveryNo===member.deliveryNo && id(outbound.deliveries[0].customerId)===id(member.customerId) + && delivery.order?.deliveryNo===member.deliveryNo && id(delivery.order.customerId)===id(member.customerId) + && delivery.order.dispatchNo===member.dispatchNo && delivery.order.outboundNo===member.outboundNo + && task.dispatchNo===member.dispatchNo && id(task.warehouseId)===id(fixture.warehouseId) && task.tripNo===fixture.tripNo) + members.push({dispatch,outbound,delivery,task,stock:await stock(member)}) + } + const value={trip,members};proof.snapshots[label]=value;return value + } + const equal = (a,b,name)=>check(name,canonical(a)===canonical(b)) + const pick = async (member,index) => { + const before=await snapshot(`BEFORE_PICK_${index}`),task=before.members[index].task + check('PDA exposes the single real current-run pallet and position',task.items?.length===1 && integer(task.items[0].scheduledPackages)===1 + && integer(task.items[0].pickedPackages)===0 && task.items[0].palletNo===member.palletNo && task.items[0].positions?.length===1 + && id(task.items[0].positions[0].id)===id(member.positionId) && integer(task.items[0].positions[0].availablePackages)===1) + const body={positionId:member.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} + const path=`${PDA}/${encode(member.dispatchNo)}/items/${encode(task.items[0].dispatchItemId)}/pick` + await request('POST',path,body,'Pick this customer only',true) + await request('POST',`${PDA}/${encode(member.dispatchNo)}/complete`,{idempotencyKey:key(`COMPLETE-${index}`)},'Complete this customer picking',true) + const after=await snapshot(`AFTER_PICK_${index}`) + equal(before.members[1-index].stock,after.members[1-index].stock,'Other customer inventory unchanged by picking') + const expected=structuredClone(before.members[index].stock) + for(const collection of ['balances','positions']) { + const row=expected[collection].find(row=>id(row.id)===id(collection==='balances'?member.balanceId:member.positionId)) + row.availablePackages=integer(row.availablePackages)-1 + } + const counts=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries((collection==='balances'?balanceBuckets:buckets).map(field=>[field,integer(row[field])]))}))])) + equal(counts(expected),counts(after.members[index].stock),'Only selected customer position and balance lose one box') + const old=before.members[index].stock.ledger,newRows=after.members[index].stock.ledger.filter(row=>!old.some(prior=>id(prior.id)===id(row.id))) + equal(old,after.members[index].stock.ledger.filter(row=>old.some(prior=>id(prior.id)===id(row.id))),'Immutable old movements preserved') + check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity',newRows.length===3 && ['ALLOCATE','PICK','SHIP'].every(type=>newRows.filter(row=>row.businessType===type + && id(row.positionId)===id(member.positionId) && row.businessOrderNo===member.dispatchNo && Number(row.deltaPackages)===(type==='SHIP'?-1:1)).length===1)) + await request('POST',path,body,'Replay exact customer pick',true) + equal(after,await snapshot(`PICK_REPLAY_${index}`),'Pick replay changes neither customer nor any execution record') + } + try { + let initial=await snapshot('INITIAL') + check('Minimal fresh fixture has one available box per customer and no prior execution',initial.trip.trip.status==='PLANNED' + && initial.members.every((row,index)=>row.dispatch.status==='PENDING_DEPARTURE' && integer(row.dispatch.packageCount)===1 + && integer(row.dispatch.pickedPackageCount)===0 && row.outbound.outbound.status==='WAIT_OUTBOUND' && row.delivery.order.status==='WAIT_DELIVERY' + && integer(row.stock.positions.find(p=>id(p.id)===id(fixture.members[index].positionId)).availablePackages)===1 + && row.stock.balances.every(balance=>balanceBuckets.filter(field=>field!=='availablePackages').every(field=>integer(balance[field])===0)) + && row.stock.balances.reduce((sum,balance)=>sum+integer(balance.availablePackages),0)===1)) + let detail=initial.trip + for(const member of fixture.members)detail=await request('POST',`${tripPath}/dispatches/${encode(member.dispatchNo)}/review`, + {recordVersion:detail.trip.recordVersion,decision:'APPROVED'},'Review each customer dispatch separately') + check('Both customer dispatches approved',detail.dispatches.every(row=>row.review?.status==='APPROVED')) + await pick(fixture.members[0],0) + const rejectedBefore=await snapshot('GATE_BEFORE') + check('Precisely one customer is ready',rejectedBefore.members[0].dispatch.status==='READY_TO_DEPART' && integer(rejectedBefore.members[1].dispatch.pickedPackageCount)===0) + await request('POST',`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`, + {recordVersion:rejectedBefore.trip.trip.recordVersion,idempotencyKey:key('UNREADY-DEPART'),remark:context.marker},'Unready second customer blocks whole trip before execution',true, + message=>message.includes(fixture.members[1].dispatchNo) && message.includes('请完成拣货并核平全部箱数')) + equal(rejectedBefore,await snapshot('GATE_AFTER'),'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') + proof.gateProof='PREREQUISITE_REJECTION_VERIFIED' + await pick(fixture.members[1],1) + const loading=await request('GET',`${tripPath}/loading`,undefined,'Read ready shared loading') + check('Both real pallets ready for loading',!loading.blockReason && loading.cargo?.length===2) + detail=await request('GET',tripPath,undefined,'Read current trip route') + const measured=controlledPalletMeasurements(loading.cargo,detail.trip.stops) + const loaded=await request('POST',`${tripPath}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:measured,reason:context.marker},'Confirm controlled TEST measurements') + const reviewed=await request('POST',`${tripPath}/loading-review`,{recordVersion:loaded.recordVersion,decision:'APPROVED'},'Approve whole-trip loading') + const ready=await snapshot('READY') + const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker} + const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart` + const departed=await request('POST',departPath,departBody,'Depart whole trip once',true) + check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2 + && canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort())) + const after=await snapshot('DEPARTED') + check('Both outbounds completed and trip in transit',after.trip.trip.status==='IN_TRANSIT' && after.members.every(row=>row.dispatch.status==='DEPARTED' && row.outbound.outbound.status==='OUTBOUNDED')) + equal(ready.members.map(row=>row.stock),after.members.map(row=>row.stock),'Departure does not deduct either inventory again') + const replay=await request('POST',departPath,departBody,'Replay exact shared departure',true) + check('Shared departure replay is explicit',replay.replayed===true) + equal(after,await snapshot('DEPART_REPLAY'),'Departure replay preserves both complete inventories and execution facts') + for(const [index,member] of fixture.members.entries()) { + const before=await snapshot(`BEFORE_SIGN_${index}`),path=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId) + await request('PUT',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/status`,member.customerId), + {status:'DELIVERING',expectedVersion:before.members[index].delivery.order.version,remark:context.marker},'Start only this customer delivery') + const documentPath=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`,member.customerId) + const docs=await request('GET',documentPath,undefined,'Read this delivery document version') + const uploaded=await context.requestMultipart('POST',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId), + {name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${member.deliveryNo}`)}) + proof.requests.push({method:'POST',path:scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),label:'Upload own synthetic receipt',http:uploaded?.http,code:uploaded?.body?.code,requestId:uploaded?.requestId,traceId:uploaded?.traceId}) + check('Receipt uploaded through correct delivery endpoint',ok(uploaded) && id(uploaded.body?.data?.fileId)) + const documents=(docs.categories||[]).filter(row=>row.category!=='RECEIPT').map(row=>({category:row.category,fileIds:(row.files||[]).map(file=>file.fileId)})) + documents.push({category:'RECEIPT',fileIds:[id(uploaded.body.data.fileId)]}) + await request('PUT',documentPath,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents},'Attach receipt to independently complete this delivery') + const signed=await snapshot(`SIGNED_${index}`) + check('This customer independently signed',signed.members[index].delivery.order.status==='DELIVERED') + equal(before.members[1-index].delivery,signed.members[1-index].delivery,'Other customer delivery is not signed or changed implicitly') + equal(before.members.map(row=>row.stock),signed.members.map(row=>row.stock),'Signing does not mutate either customer inventory') + // Retain the independently readable result as evidence, not a fabricated completion command. + await request('GET',path,undefined,'Read signed customer delivery') + } + proof.status='VERIFIED';return proof + } catch(error) {proof.status='FAILED';proof.error=error.message;throw error} +} diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs new file mode 100644 index 000000000..1aaef496c --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -0,0 +1,211 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createFleetCrossCustomerFixture, preparationContract } from './fleet-cross-customer-fixture.mjs' +import { fixtureContract } from './fleet-cross-customer-acceptance.mjs' + +// Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too. +function model(fault = '') { + const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = [] + const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' } + const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30, + vehiclePlate: 'TEST', driverName: 'TEST', driverPhone: '19900000000' }, dispatches: [] } + const origin = { warehouse: { id: '10', tenantId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE', warehouseCode: 'ORIGIN', warehouseName: 'Origin' }, + capabilities: [{ capabilityCode: 'RECEIVING', enabled: fault !== 'disabled receiving' }] } + const destination = { warehouse: { id: '20', tenantId: '0', warehouseType: 'FBA', status: 'ACTIVE', warehouseCode: 'FBA', warehouseName: 'Destination' }, + addresses: [{ addressLine1: 'TEST street', countryCode: 'US' }] } + const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' }) + const buckets = { availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0' } + const stock = transfer => { + const n = transfers.indexOf(transfer), scope = { customerId: transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo } + const position = { ...scope, ...buckets, id: `P${n}`, balanceId: `B${n}`, palletId: `PAL${n}`, palletNo: `PALLET${n}` } + if (fault === 'foreign position') position.customerId = 'OTHER' + return { balance: { ...scope, ...buckets, id: `B${n}`, inTransitPackages: '0', procurementPackages: '0' }, positions: [position], + ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo, + beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] } + } + const cargo = () => trip.dispatches.map(binding => { + const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d) + return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, + destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 } + }) + const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' } + const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture }, + createdResources: { merchantQualificationFixture: { marker: 'PARENT' } }, + assert(_module, label, _response, passed) { assert.ok(passed, label) }, + apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data }, + async findAvailablePhone() { return `1990000000${customers.length}` }, + async requestWithBody(method, raw, form) { + calls.push({ method, raw, multipart: true }); assert.ok(form instanceof FormData) + assert.match(raw, /^\/api\/platform\/v1\/files\/qualifications\/\d+\/required-package\?/) + return response({}) + }, + async requestMultipart(method, raw, file, token = this.token) { + calls.push({ method, raw, token, multipart: true }); assert.ok(file.content.length > 0) + if (raw.includes('/license?')) return response({ id: 'DRIVER', recordVersion: '1', licenseFileId: 'LICENSE' }) + assert.match(raw, /\/warehouse-transfers\/IN[01]\/measurement-photos\/files$/); assert.equal(token, options.pdaToken) + return response({ fileId: 'PHOTO', sizeBytes: file.content.length }) + }, + async request(method, raw, body, token = this.token) { + calls.push({ method, raw, body: copy(body), token }) + const url = new URL(raw, 'https://offline.invalid'), path = url.pathname + if (path.startsWith('/api/platform/')) assert.equal(token, 'admin-secret') + if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken) + if (path.endsWith('/warehouse-admin/10')) return response(origin) + if (path.endsWith('/warehouse-admin/20')) return response(destination) + if (path === '/api/platform/v1/iam/tenants') { + const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n) + const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark } + customers.push(customer) + return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password }) + } + if (path.endsWith('/submit-review')) return response({ status: 'PENDING' }) + if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id }))) + if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' }) + if (path.endsWith('/approve')) return response({}) + if (path.endsWith('/qualifications')) return response(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' }))) + if (path === '/api/auth/login') { + assert.equal(token, '') + const customer = customers.find(row => row.username === body.loginId); assert.equal(body.password, customer.password) + return response({ accessToken: `merchant-${customer.id}-secret`, refreshToken: `refresh-${customer.id}-secret` }) + } + if (path === '/api/auth/logout') { + assert.equal(body.refreshToken, token.replace('merchant-', 'refresh-')) + if (fault === 'logout failure' && token.includes('100')) throw new Error('offline logout failure') + return response(null) + } + const merchant = customers.find(row => token === `merchant-${row.id}-secret`) + if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id }) + if (path === '/api/merchant/v1/last-leg/warehouse-transfers') { + assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, []) + assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1) + const transferNo = `IN${transfers.length}` + const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId, + status: 'DRAFT', pallets: [] } + transfers.push(transfer); return response(transfer) + } + if (path.includes('/warehouse-transfers/')) { + const transfer = transfers.find(row => path.includes(`/${row.transferNo}`)), n = transfers.indexOf(transfer) + assert.ok(transfer) + if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id) + if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW' + else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' } + else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }] + else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` }) + else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' } + else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' } + else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH' + else assert.equal(method, 'GET') + return response(transfer) + } + if (path.includes('/inventory/')) { + const transfer = transfers.find(row => row.customerId === url.searchParams.get('customerId')), facts = stock(transfer) + if (path.endsWith('/positions')) return response(facts.positions) + if (/\/balances\/B[01]$/.test(path)) return response(facts.balance) + // Match InventoryLedgerView: the public DTO has neither balanceId nor positionId. + if (path.endsWith('/ledger')) return response({ records: facts.ledger.map(({ balanceId, positionId, ...view }) => view), total: fault === 'truncated ledger' ? 2 : 1 }) + assert.ok(path.endsWith('/balances')); return response({ records: [facts.balance], total: 1 }) + } + if (path.endsWith('/dispatches/candidates')) { + const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n] + assert.equal(transfer.status, 'READY_FOR_DISPATCH') + return response([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo, + warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }]) + } + if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` }) + if (path.endsWith('/draft-locks/lock')) return response(null) + if (path.endsWith('/pre-arrange')) { + const n = Number(body.planPalletIds[0].slice(-1)) + const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }], + packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 } + dispatches.push(dispatch); return response(dispatch) + } + if (path.endsWith('/master-data/vehicles')) return response({ id: 'VEHICLE' }) + if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' }) + if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' }) + if (path.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) } + if (path.includes('/fleet/trips/TRIP')) { + if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() }) + if (method !== 'GET') { + assert.equal(body.recordVersion, trip.trip.recordVersion) + trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1) + } + if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId }) + else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } }) + else if (path.endsWith('/plan')) trip.trip.stops = body.stops + else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED' + return response(trip) + } + const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n] + const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' } + if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] }) + if (path.endsWith('/transfer-delivery')) { + assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30) + return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 }) + } + if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) } + if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] }) + if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch) + throw new Error(`Unexpected offline request ${method} ${path}`) + }, + } + options.readInventory = async scope => { + assert.equal(scope.marker, context.marker); assert.equal(scope.warehouseId, options.warehouseId) + const facts = stock(transfers.find(row => row.customerId === scope.customerId)) + const raw = { scope: { ...scope, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [facts.balance], positions: facts.positions, ledger: facts.ledger } + if (fault === 'wrong raw scope') raw.scope.marker = 'OLD' + if (fault === 'wrong raw ledger position') raw.ledger[0].positionId = 'OTHER' + if (fault === 'wrong raw ledger balance') raw.ledger[0].balanceId = 'OTHER' + return copy(raw) + } + return { context, options, calls, customers, transfers, trip, parentFixture } +} + +test('default preparation is network-free and exposes caller-owned lifecycle', async () => { + assert.equal(await createFleetCrossCustomerFixture(), preparationContract) + assert.equal(preparationContract.stage, fixtureContract.stage) +}) + +test('real helpers build independent current-run customers, receipts and one complete shared trip without picking', async () => { + const m = model(), oldEnvironment = { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD } + const fixture = await createFleetCrossCustomerFixture(m.context, m.options) + assert.equal(fixture.stage, fixtureContract.stage) + assert.equal(fixture.members.length, 2); assert.notEqual(fixture.members[0].customerId, fixture.members[1].customerId) + assert.ok(fixture.members.every(row => row.receiptVerified && row.marker === m.context.marker && row.positionId && row.balanceId)) + assert.equal(m.context.context.merchantQualificationFixture, m.parentFixture) + assert.deepEqual(m.context.createdResources.merchantQualificationFixture, { marker: 'PARENT' }) + assert.equal(m.context.options.customerName, 'Parent customer') + assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, oldEnvironment) + assert.notEqual(m.customers[0].marker, m.customers[1].marker) + const proof = m.context.createdResources.fleetCrossCustomerFixture + assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT']) + assert.equal(m.trip.dispatches.length, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA']) + assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2) + assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2) + assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw))) + const persisted = JSON.stringify(m.context.createdResources) + for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret)) +}) + +for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) { + test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => { + const m = model(fault) + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options)) + const proof = m.context.createdResources.fleetCrossCustomerFixture + assert.equal(proof.status, 'FAILED') + const logins = m.calls.filter(row => row.raw === '/api/auth/login'), logouts = m.calls.filter(row => row.raw === '/api/auth/logout') + assert.equal(logouts.length, logins.length) + assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/'))) + if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0) + const previousCount = m.calls.length + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/) + assert.equal(m.calls.length, previousCount) + }) +} + +test('missing explicit warehouse or caller PDA fails before reading or writing APIs', async () => { + for (const field of ['warehouseId', 'destinationWarehouseId', 'pdaToken', 'readInventory']) { + const m = model(); delete m.options[field] + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Missing/) + assert.equal(m.calls.length, 0) + } +}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs new file mode 100644 index 000000000..cbfd4812c --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto' +import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs' +import { prepareFleetTrip } from './fleet-trip-fulfillment.mjs' +import { fixtureContract } from './fleet-cross-customer-acceptance.mjs' + +const MODULE = 'fleet-cross-customer-fixture' +const ROOT = '/api/platform/v1/last-leg' +const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const encode = encodeURIComponent +const ok = response => response?.http === 200 && id(response.body?.code) === '200' +const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const balanceBuckets = [...buckets, 'inTransitPackages', 'procurementPackages'] +const testImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') +// LocalDateTime API fields use the runner's local wall clock, with no misleading UTC suffix. +const localTime = delay => { + const date = new Date(Date.now() + delay) + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) +} + +export const preparationContract = Object.freeze({ + stage: fixtureContract.stage, + inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope', + writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; two dispatches, one shared trip with a test vehicle/qualified driver, two outbounds and deliveries. No picking.', + lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.', +}) + +/** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */ +export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, pdaToken, readInventory } = {}) { + if (!execute) return preparationContract + if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function' + || ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function')) + throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader') + context.createdResources ??= {} + if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly') + const proof = context.createdResources.fleetCrossCustomerFixture = { + marker: context.marker, warehouseId: id(warehouseId), destinationWarehouseId: id(destinationWarehouseId), status: 'PREPARING', + members: [], requests: [], sessionCleanup: [], lifecycle: preparationContract.lifecycle, + } + const sessions = [] + const check = (label, passed) => { + context.assert(MODULE, label, null, Boolean(passed), '', OPTIONS) + if (!passed) throw new Error(label) + } + const key = label => createHash('sha256').update(`${context.marker}:CROSS-FIXTURE:${label}`).digest('hex') + const record = (method, path, body, response, label) => { + // Auth payloads contain credentials; never persist them or response tokens. + proof.requests.push({ method, path, body: path.startsWith('/api/auth/') ? undefined : body, label, + http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) + check(label, ok(response) && response.requestId && response.traceId) + return response.body.data + } + const request = async (method, path, body, label, token = context.token) => + record(method, path, body, await context.request(method, path, body, token), label) + const own = member => new URLSearchParams({ customerId: member.customerId }) + const stock = async member => { + const query = new URLSearchParams({ customerId: member.customerId, overseasWarehouseId: id(warehouseId), + inboundCode: member.incomingNo, page: '1', size: '200' }) + const grouped = await request('GET', `${ROOT}/inventory/balances?${query}`, undefined, 'Read exact fresh receipt balance page') + const ledger = await request('GET', `${ROOT}/inventory/ledger?${query}&businessOrderNo=${encode(member.incomingNo)}`, undefined, 'Read exact receipt movement page') + check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && Number(grouped.total) === 1 + && ledger?.records?.length === 1 && Number(ledger.total) === 1 && id(grouped.records[0].id)) + const path = `${ROOT}/inventory/balances/${encode(grouped.records[0].id)}` + const balance = await request('GET', `${path}?${own(member)}`, undefined, 'Read original raw balance identity') + const positions = await request('GET', `${path}/positions?${own(member)}`, undefined, 'Read original raw physical position') + const sourceMatches = row => id(row.customerId) === member.customerId && id(row.overseasWarehouseId) === id(warehouseId) + && row.inboundCode === member.incomingNo + check('Receipt raw balance and position belong to exactly this customer and physical pallet', balance && sourceMatches(balance) + && id(balance.id) === id(grouped.records[0].id) && positions?.length === 1 && id(positions[0].id) && sourceMatches(positions[0]) + && id(positions[0].balanceId) === id(balance.id) && id(positions[0].palletId) === member.palletId && positions[0].palletNo === member.palletNo + && balanceBuckets.every(field => String(balance[field]) === (field === 'availablePackages' ? '1' : '0')) + && buckets.every(field => String(positions[0][field]) === (field === 'availablePackages' ? '1' : '0'))) + const movement = ledger.records[0] + check('Exactly one immutable STOCK_IN movement proves the real receipt', sourceMatches(movement) && id(movement.id) + && movement.businessType === 'STOCK_IN' && movement.businessOrderNo === member.incomingNo + && movement.palletNo === member.palletNo + && String(movement.beforePackages) === '0' && String(movement.deltaPackages) === '1' && String(movement.afterPackages) === '1') + // InventoryLedgerView intentionally exposes no balanceId/positionId. Only the complete raw reader proves those links. + const scope = { marker: context.marker, customerId: member.customerId, warehouseId: id(warehouseId) } + const raw = await readInventory(scope) + check('Raw receipt reader proves complete current customer and warehouse scope', raw?.scope?.marker === scope.marker + && raw.scope.customerId === scope.customerId && raw.scope.warehouseId === scope.warehouseId + && raw.scope.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE') + for (const collection of ['balances', 'positions', 'ledger']) check(`Fresh customer raw ${collection} has exactly one original receipt row`, + Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id) && sourceMatches(raw[collection][0])) + const rawBalance = raw.balances[0], rawPosition = raw.positions[0], rawMovement = raw.ledger[0] + check('Raw receipt identities and quantities match the public evidence', id(rawBalance.id) === id(balance.id) + && id(rawPosition.id) === id(positions[0].id) && id(rawPosition.balanceId) === id(balance.id) + && id(rawPosition.palletId) === member.palletId && rawPosition.palletNo === member.palletNo + && balanceBuckets.every(field => String(rawBalance[field]) === String(balance[field])) + && buckets.every(field => String(rawPosition[field]) === String(positions[0][field])) + && id(rawMovement.id) === id(movement.id) && id(rawMovement.balanceId) === id(balance.id) && id(rawMovement.positionId) === id(positions[0].id) + && rawMovement.businessType === 'STOCK_IN' && rawMovement.businessOrderNo === member.incomingNo + && rawMovement.palletNo === member.palletNo && ['beforePackages', 'deltaPackages', 'afterPackages'].every(field => String(rawMovement[field]) === String(movement[field]))) + return { balance, positions, ledger: ledger.records, raw } + } + let fixture, failure + try { + // Explicit IDs are re-read through the public master-data API before any fixture writes. + const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse') + const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationWarehouseId)}?tenantId=0`, undefined, 'Verify FBA destination') + const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId) + && id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type + && detail.warehouse.warehouseCode && detail.warehouse.warehouseName + check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS') + && origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true) + && validMaster(destination, destinationWarehouseId, 'FBA')) + const address = destination.addresses?.find(row => row.addressLine1) + check('FBA destination has a real address', Boolean(address)) + const deliveryAddress = [address.countryCode, address.stateProvince, address.city, address.district, + address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') + for (const suffix of ['A', 'B']) { + const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false } + proof.members.push(member) + // Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot. + const child = Object.create(context) + child.marker = member.customerMarker + child.options = { ...context.options, customerName: `跨客户车次-${key(suffix).slice(0, 12)}-${suffix}` } + child.context = {}; child.createdResources = {} + child.request = async (method, path, body, token = context.token) => { + const response = await context.request(method, path, body, token) + record(method, path, body, response, `Qualify fresh customer ${suffix}`) + if (method === 'POST' && path === '/api/platform/v1/iam/tenants') + member.customerId = id((response.body.data?.tenant || response.body.data?.customer || response.body.data)?.id || response.body.data?.tenantId) + return response + } + await createQualifiedMerchant(child, { publishEnvironment: false }) + const credentials = child.context.merchantQualificationFixture + check('Qualification belongs to this independent current-run context', credentials?.marker === member.customerMarker + && id(credentials.tenantId) === member.customerId && proof.members.filter(row => row.customerId === member.customerId).length === 1) + member.qualification = child.createdResources.merchantQualificationFixture + let auth + try { + auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login fresh customer ${suffix}`, '') + } finally { delete child.context.merchantQualificationFixture } + // Retain a returned session for cleanup even if the login contract is incomplete. + if (auth?.accessToken) sessions.push({ customerId: member.customerId, accessToken: auth.accessToken, refreshToken: auth.refreshToken }) + check('Fresh customer session is complete', auth?.accessToken && auth.refreshToken) + const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken) + check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId) + const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId), + fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(destinationWarehouseId), + destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName, + deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000), + needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`), + items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1 }] } + let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken) + member.incomingNo = incoming?.transferNo + check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo + && incoming.status === 'DRAFT' && id(incoming.operatingWarehouseId) === id(warehouseId)) + const merchantPath = `${MERCHANT}/${encode(member.incomingNo)}` + incoming = await request('POST', `${merchantPath}/submit`, undefined, `Submit own incoming ${suffix}`, auth.accessToken) + check('Own incoming submitted for review', incoming?.status === 'PENDING_REVIEW') + incoming = await request('POST', `${ROOT}/warehouse-transfers/${encode(member.incomingNo)}/review?${own(member)}`, + { decision: 'APPROVE', remark: member.customerMarker }, `Approve incoming ${suffix}`) + check('Approved fresh incoming awaits real PDA receipt', incoming?.status === 'WAIT_PDA_RECEIVING') + const pdaPath = `/api/pda/v1/last-leg/warehouse-transfers/${encode(member.incomingNo)}` + const task = await request('GET', pdaPath, undefined, 'PDA warehouse binding resolves exact new incoming', pdaToken) + check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo + && task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId) + && task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0) + const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`), + cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, 'Measure one real test pallet', pdaToken) + check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT') + const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}` + const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken) + const pallet = generated?.pallets?.[0] + check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo) + member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo + const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken) + check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND' + && id(bound.pallets[0].warehousePalletId) === member.palletId && Number(bound.pallets[0].cartonCount) === 1) + const photoPath = `${pdaPath}/measurement-photos/files` + const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath, + { name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence') + check('Measurement evidence is a stored file', photo?.fileId && Number(photo.sizeBytes) > 0) + incoming = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${suffix}`), photoFileIds: [id(photo.fileId)] }, 'Complete real PDA receipt', pdaToken) + check('Real PDA receipt completed with original physical pallet', incoming?.status === 'WAIT_CUSTOMER_CONFIRMATION' + && incoming.pallets?.length === 1 && incoming.pallets[0].status === 'COMPLETED' && id(incoming.pallets[0].warehousePalletId) === member.palletId) + incoming = await request('POST', `${merchantPath}/confirm-measurement`, { idempotencyKey: key(`CONFIRM-${suffix}`), remark: member.customerMarker }, 'Customer confirms measured incoming', auth.accessToken) + check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH') + member.receipt = await stock(member) + member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true + const dispatchBase = `${ROOT}/dispatches` + const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: member.incomingNo })}`, undefined, 'Read own real received dispatch candidate') + check('Candidate has exact customer, incoming, warehouse and physical pallet ownership', candidates?.length === 1 + && id(candidates[0].customerId) === member.customerId && id(candidates[0].overseasWarehouseId) === id(warehouseId) + && candidates[0].inboundCode === member.incomingNo && id(candidates[0].warehousePalletId) === member.palletId + && candidates[0].palletNo === member.palletNo && candidates[0].sourceStage === 'RECEIVED' && Number(candidates[0].packageCount) === 1 && candidates[0].planPalletId) + member.draftToken = key(`DRAFT-${suffix}`) + const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: member.draftToken, capacity: 30 }, 'Create isolated dispatch draft') + check('Dispatch draft has explicit container identity', container?.id) + member.draftContainerId = id(container.id) + await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken: member.draftToken, + draftContainerId: member.draftContainerId }, 'Lock this customer received pallet') + const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken: member.draftToken }, 'Prearrange one independent customer dispatch') + check('Dispatch contains exactly this unpicked pallet', dispatch?.dispatchNo && dispatch.status === 'PENDING_DEPARTURE' + && dispatch.items?.length === 1 && id(dispatch.items[0].warehousePalletId) === member.palletId + && Number(dispatch.packageCount) === 1 && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + member.dispatchNo = dispatch.dispatchNo + } + for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId', 'dispatchNo']) + check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2) + const tripProof = await prepareFleetTrip(context, MODULE, proof.members[0].dispatchNo, id(warehouseId)) + proof.tripNo = tripProof.tripNo + const tripPath = `${ROOT}/fleet/trips/${encode(proof.tripNo)}` + let detail = await request('GET', tripPath, undefined, 'Read first member trip version') + detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, + dispatchNo: proof.members[1].dispatchNo, reason: context.marker }, 'Bind second independent customer dispatch') + const loading = await request('GET', `${tripPath}/loading`, undefined, 'Read complete two-customer cargo destinations') + check('Shared trip cargo consists of exactly both owned physical pallets', loading?.cargo?.length === 2 && proof.members.every(member => + loading.cargo.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId + && id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destinationKey && Number(row.packages) === 1))) + const destinations = new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])) + detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, + plannedDepartureAt: detail.trip.plannedDepartureAt, expectedDriverEndAt: detail.trip.expectedDriverEndAt, + stops: [{ name: origin.warehouse.warehouseName }, ...destinations.values()], reason: context.marker }, 'Replace route with complete actual destinations') + tripProof.stops = detail.trip.stops + check('Route includes every actual destination', [...destinations.keys()].every(destinationKey => detail.trip.stops?.slice(1).some(row => row.destinationKey === destinationKey))) + for (const [index, member] of proof.members.entries()) { + const path = `${ROOT}/dispatches/${encode(member.dispatchNo)}` + const freight = await request('GET', `${path}/external-freight`, undefined, 'Verify ordinary incoming has no external quote') + check('Ordinary incoming requires its own explicit delivery fee', freight?.requiresDeliveryFee === true && freight.quotes?.length === 0) + const transferred = await request('POST', `${path}/transfer-delivery`, { idempotencyKey: key(`DELIVERY-${index}`), plannedDeliveryAt: localTime(21600000), + deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', + vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate, + driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', remark: member.customerMarker, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '受控测试派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, 'Create this customer outbound and delivery') + check('One customer dispatch produces one outbound and one delivery', transferred?.dispatchNo === member.dispatchNo && transferred.outboundNo + && transferred.deliveries?.length === 1 && transferred.deliveries[0].deliveryNo && Number(transferred.packageCount) === 1) + member.outboundNo = transferred.outboundNo; member.deliveryNo = transferred.deliveries[0].deliveryNo + const delivery = await request('GET', `${ROOT}/delivery-orders/${encode(member.deliveryNo)}?${own(member)}`, undefined, 'Verify independently owned delivery') + const outbound = await request('GET', `${ROOT}/outbound-orders/${encode(member.outboundNo)}?${own(member)}`, undefined, 'Verify independently owned outbound') + const dispatch = await request('GET', path, undefined, 'Verify customer dispatch remains unpicked') + check('Customer execution records are distinct and untouched', id(delivery?.order?.customerId) === member.customerId + && delivery.order.deliveryNo === member.deliveryNo && delivery.order.dispatchNo === member.dispatchNo && delivery.order.outboundNo === member.outboundNo + && delivery.order.status === 'WAIT_DELIVERY' && outbound?.outbound?.outboundNo === member.outboundNo && outbound.outbound.dispatchNo === member.dispatchNo + && id(outbound.outbound.overseasWarehouseId) === id(warehouseId) && outbound.outbound.status === 'WAIT_OUTBOUND' + && outbound.deliveries?.length === 1 && id(outbound.deliveries[0].customerId) === member.customerId && outbound.deliveries[0].deliveryNo === member.deliveryNo + && dispatch?.status === 'PENDING_DEPARTURE' && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + const finalStock = await stock(member) + check('Trip preparation preserves original available receipt records', JSON.stringify(finalStock) === JSON.stringify(member.receipt)) + } + detail = await request('GET', tripPath, undefined, 'Verify final two-customer shared trip membership') + check('Final trip has only the two fresh customers and remains planned', detail?.trip?.status === 'PLANNED' && detail.dispatches?.length === 2 + && proof.members.every(member => detail.dispatches.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId))) + for (const field of ['outboundNo', 'deliveryNo']) check(`Independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2) + fixture = { marker: context.marker, stage: fixtureContract.stage, tripNo: proof.tripNo, warehouseId: id(warehouseId), pdaToken, + members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified }) => + ({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified })) } + } catch (error) { failure = error } + // Always attempt every successfully opened session, including partial fixture failures. PDA lifecycle belongs to caller. + const cleanupErrors = [] + for (const session of sessions) { + try { + await request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, 'Logout owned merchant fixture session', session.accessToken) + proof.sessionCleanup.push({ customerId: session.customerId, status: 'LOGGED_OUT' }) + } catch (error) { + cleanupErrors.push(error); proof.sessionCleanup.push({ customerId: session.customerId, status: 'FAILED' }) + } + } + proof.status = failure || cleanupErrors.length ? 'FAILED' : 'READY' + if (failure && !cleanupErrors.length) throw failure + if (cleanupErrors.length) throw new AggregateError([...(failure ? [failure] : []), ...cleanupErrors], 'Fixture/session cleanup failed; retain recorded resources') + return fixture +} diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs new file mode 100644 index 000000000..050319093 --- /dev/null +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -0,0 +1,143 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { dirname, resolve, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { redactEvidence } from './fba-core-regression-test-env.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ok = response => response?.http === 200 && String(response.body?.code) === '200' +export function parseCrossCustomerArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan', warehouseId: '', destinationWarehouseId: '' } + const seen = new Set() + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (['--execute', '--plan'].includes(arg)) { + if (seen.has('action')) throw Error('Duplicate execution mode') + seen.add('action'); config.execute = arg === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--warehouse-id': 'warehouseId', '--destination-warehouse-id': 'destinationWarehouseId' }[arg] + if (!field || seen.has(arg) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('Unsupported or duplicate argument') + seen.add(arg); config[field] = args[++i] + } + if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || (config.warehouseId && !/^[1-9][0-9]{0,19}$/.test(config.warehouseId)) + || (config.destinationWarehouseId && !/^[1-9][0-9]{0,19}$/.test(config.destinationWarehouseId)) + || (config.execute && (!seen.has('--environment') || config.runId === 'plan' || !config.warehouseId || !config.destinationWarehouseId))) throw Error('Explicit TEST, fresh run-id and warehouse/destination IDs required') + return config +} +export function crossCustomerPlan(config) { + return { environment: 'TEST', execute: config.execute, runId: config.runId, warehouseId: config.warehouseId || null, destinationWarehouseId: config.destinationWarehouseId || null, + output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'two dispatches on one trip', + 'unready customer gate rejection', 'shared departure and replay', 'independent receipts'], + limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' } +} + +export async function runCrossCustomer(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, + contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}) { + const plan = crossCustomerPlan(config) + if (!config.execute) return plan + if (config.environment !== 'TEST' || env.CHONGCHONG_ENV !== 'test' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || config.runId === 'plan' || !/^[1-9][0-9]{0,19}$/.test(config.warehouseId) || !/^[1-9][0-9]{0,19}$/.test(config.destinationWarehouseId) + || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('Explicit TEST configuration required') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || !['/api', '/api/'].includes(base.pathname)) throw Error('Invalid TEST gateway') + readers ||= createTestEvidenceReaders(env) + // Load the reviewed producer before creating any resources. + prepare ||= (await import('./admin-test-env-modules/fleet-cross-customer-fixture.mjs')).createFleetCrossCustomerFixture + if (typeof prepare !== 'function' || typeof readers.readFleetInventory !== 'function') throw Error('Fixture producer and inventory reader required') + const reports = resolve(outputRoot, 'reports'), output = join(reports, `fleet-cross-${config.runId}`) + mkdirSync(reports, { recursive: true }) + const lockPath = join(reports, '.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') + let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0 + const sessions = new Map() + const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN/.test(name)).map(([, value]) => value) + const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, secrets), null, 2) + '\n') + try { + writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) + mkdirSync(output); initialized = true; mkdirSync(join(output, 'evidence')); mkdirSync(join(output, 'requests')) + const sources = ['scripts/fleet-cross-customer-test-env.mjs', 'scripts/fleet-inventory-evidence.mjs', 'scripts/fba-core-regression-evidence.mjs', + 'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', + 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') })) + save('manifest.json', { ...plan, sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(), sources }) + await readers.preflight() + const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), + environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) + context = contextFactory(options) + for (const method of ['request', 'requestWithBody']) { + const original = context[method] + context[method] = async function (...args) { + const number = ++requestIndex, [verb, path, body] = args + const record = { number, method: verb, path, stage: 'INTENT', body: body instanceof FormData ? '[multipart file]' : body } + save(`requests/${number}.json`, record) + const response = await original.apply(this, args) + if (verb === 'POST' && path === '/api/platform/v1/iam/users' && ok(response) + && String(body?.overseasWarehouseId) === config.warehouseId && body?.username?.startsWith('pda_overseas_')) { + createdPicker = { membershipId: response.body?.data?.membershipId } + } + if (path === '/api/auth/login' && ok(response) && response.body?.data?.accessToken) { + const tokens = response.body.data + sessions.set(tokens.accessToken, tokens.refreshToken) + secrets.push(tokens.accessToken, tokens.refreshToken) + } + save(`requests/${number}.json`, { ...record, stage: 'RESPONSE', response }) + save('progress.json', { marker: context.marker, steps: context.steps, createdResources: context.createdResources }) + return response + } + } + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + if (!ok(login) || !login.body.data?.accessToken) throw Error('TEST administrator login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken + const warehouses = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS') + if (!ok(warehouses) || !warehouses.body.data?.some(row => String(row.id) === config.warehouseId)) throw Error('Requested warehouse not available') + const access = await pickerAccess(context) + if (!access) throw Error('PDA role unavailable') + picker = await pickerCreate(context, 81, config.warehouseId, access) + if (!picker?.token) throw Error('Temporary PDA login failed') + secrets.push(picker.password, picker.token, picker.refreshToken) + const readInventory = async scope => { + const result = await readers.readFleetInventory(scope) + save(`evidence/inventory-${++evidenceIndex}.json`, result); return result + } + const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) + save('fixture.json', fixture) + await verify(context, fixture, { execute: true, readInventory }) + report = { success: context.createdResources.fleetCrossCustomer?.status === 'VERIFIED' } + } catch (error) { + report = { success: false, error: error instanceof Error ? error.message : 'CROSS_CUSTOMER_FAILED' } + } finally { + if (context && (picker?.membershipId || createdPicker?.membershipId)) { + try { await pickerDisable(context, picker?.membershipId ? picker : createdPicker, 81) } catch { report.success = false; report.cleanupError = 'PDA disable failed' } + } + if (context) { + let sessionsClosed = true + for (const [accessToken, refreshToken] of sessions) { + const response = await context.request('POST', '/api/auth/logout', { refreshToken }, accessToken) + if (!ok(response) && response.http !== 401 && String(response.body?.code) !== 'A0401') sessionsClosed = false + } + report.sessionsClosed = sessionsClosed + report.summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) + report.success &&= sessionsClosed && report.summary.failed === 0 + report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps + } + report ||= { success: false } + report.requestCount = requestIndex; report.inventorySnapshots = evidenceIndex; report.goalComplete = false + try { if (initialized) save('report.json', report) } finally { closeSync(lock); unlinkSync(lockPath) } + } + return report +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseCrossCustomerArguments(process.argv.slice(2)) + const result = await runCrossCustomer(config) + console.log(JSON.stringify(config.execute ? { success: result.success, error: result.error, summary: result.summary, + requestCount: result.requestCount, inventorySnapshots: result.inventorySnapshots, output: crossCustomerPlan(config).output } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/scripts/fleet-cross-customer-test-env.test.mjs b/scripts/fleet-cross-customer-test-env.test.mjs new file mode 100644 index 000000000..7b2bad171 --- /dev/null +++ b/scripts/fleet-cross-customer-test-env.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, basename, resolve } from 'node:path' +import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' + +const config = () => parseCrossCustomerArguments(['--execute', '--environment', 'TEST', '--run-id', 'offline-one', '--warehouse-id', '900', '--destination-warehouse-id', '901']) +const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.example.invalid/api', CHONGCHONG_ADMIN_USERNAME: 'fixture-admin', CHONGCHONG_ADMIN_PASSWORD: 'private-admin-password' } +function harness(root, fail = false) { + const calls = [], cleanup = [] + const context = { marker: 'CROSS-OFFLINE', steps: [], modules: [], coverageGaps: [], context: {}, createdResources: {}, + async request(method, path) { + calls.push({ method, path }) + const data = path === '/api/auth/login' ? { accessToken: 'private-access', refreshToken: 'private-refresh' } + : path.includes('warehouse-options') ? [{ id: '900' }] : {} + return { http: 200, body: { code: '200', data } } + }, async requestWithBody() { throw Error('Unexpected multipart') } } + return { calls, cleanup, context, options: { env, outputRoot: root, contextFactory: () => context, + readers: { preflight: async () => {}, readFleetInventory: async scope => ({ scope, balances: [], positions: [], ledger: [] }) }, + pickerAccess: async () => ({}), pickerCreate: async () => ({ membershipId: '81', token: 'private-pda', password: 'private-pda-password' }), + pickerDisable: async (_context, picker) => { cleanup.push(picker.membershipId) }, + prepare: async (_context, options) => { + assert.equal(options.pdaToken, 'private-pda') + await context.request('POST', '/api/test-owned-command', { idempotencyKey: 'stable-command', password: 'private-pda-password' }) + if (fail) throw Error('FIXTURE_FAILED') + return { pdaToken: options.pdaToken, marker: context.marker } + }, verify: async (_context, _fixture, options) => { + await options.readInventory({ marker: context.marker, customerId: '42', warehouseId: '900' }) + context.createdResources.fleetCrossCustomer = { status: 'VERIFIED' } + }, + } } +} +test('plan is network-free and execution requires explicit TEST, unique run and warehouse', async () => { + const plan = await runCrossCustomer(parseCrossCustomerArguments([]), { env: {}, prepare: () => { throw Error('must not execute') } }) + assert.equal(plan.execute, false) + for (const args of [['--execute'], ['--execute', '--environment', 'PROD', '--run-id', 'one', '--warehouse-id', '900'], ['--run-id', '../outside'], ['--plan', '--execute'], ['--warehouse-id', '1 OR 1=1']]) assert.throws(() => parseCrossCustomerArguments(args)) + await assert.rejects(runCrossCustomer(config(), { env: { ...env, CHONGCHONG_ENV: 'prod' } }), /TEST/) +}) +for (const fail of [false, true]) test(`execution ${fail ? 'failure' : 'success'} preserves evidence, closes session and disables only its operator`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root, fail), report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, !fail) + assert.deepEqual(h.cleanup, ['81']) + assert.equal(h.calls.filter(c => c.path === '/api/test-owned-command').length, 1) + assert.equal(h.calls.filter(c => c.path === '/api/auth/logout').length, 1) + assert.equal(report.sessionsClosed, true) + assert.equal(existsSync(join(root, 'reports/.fleet-acceptance.lock')), false) + const folder = join(root, 'reports/fleet-cross-offline-one') + for (const file of readdirSync(join(folder, 'requests'))) { + const saved = readFileSync(join(folder, 'requests', file), 'utf8') + assert.doesNotMatch(saved, /private-admin-password|private-access|private-refresh|private-pda/) + } + const originalReport = readFileSync(join(folder, 'report.json'), 'utf8') + const replay = await runCrossCustomer(config(), h.options) + assert.equal(replay.success, false) + assert.equal(readFileSync(join(folder, 'report.json'), 'utf8'), originalReport) + assert.equal(h.calls.filter(c => c.path === '/api/test-owned-command').length, 1) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.ok(basename(root).startsWith('fleet-cross-contract-')) + rmSync(root, { recursive: true, force: true }) + } +}) + +test('operator creation followed by login failure still disables the newly created membership', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root), original = h.context.request + h.context.request = async function (...args) { + if (args[1] === '/api/platform/v1/iam/users') return { http: 200, body: { code: '200', data: { membershipId: '82' } } } + return original.apply(this, args) + } + h.options.pickerCreate = async context => { + await context.request('POST', '/api/platform/v1/iam/users', { username: 'pda_overseas_current_81', overseasWarehouseId: '900', password: 'private-pda-password' }) + throw Error('PDA_LOGIN_FAILED') + } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, false) + assert.deepEqual(h.cleanup, ['82']) + assert.equal(h.calls.some(row => row.path === '/api/test-owned-command'), false) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.ok(basename(root).startsWith('fleet-cross-contract-')) + rmSync(root, { recursive: true, force: true }) + } +}) -- 2.54.0 From 299287197547448a6e73a64bc1692b5a4c5584b4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 07:44:17 +0800 Subject: [PATCH 080/176] test(fleet): resolve raw position ownership through its balance --- .../fleet-cross-customer-fixture-contract.test.mjs | 2 ++ .../admin-test-env-modules/fleet-cross-customer-fixture.mjs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 1aaef496c..6ad5a84cf 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -152,6 +152,8 @@ function model(fault = '') { assert.equal(scope.marker, context.marker); assert.equal(scope.warehouseId, options.warehouseId) const facts = stock(transfers.find(row => row.customerId === scope.customerId)) const raw = { scope: { ...scope, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [facts.balance], positions: facts.positions, ledger: facts.ledger } + // The physical position table links to its inbound through balance_id; only the public joined DTO has inboundCode. + for (const position of raw.positions) delete position.inboundCode if (fault === 'wrong raw scope') raw.scope.marker = 'OLD' if (fault === 'wrong raw ledger position') raw.ledger[0].positionId = 'OTHER' if (fault === 'wrong raw ledger balance') raw.ledger[0].balanceId = 'OTHER' diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs index cbfd4812c..0a345ce51 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -83,7 +83,9 @@ export async function createFleetCrossCustomerFixture(context, { execute = false && raw.scope.customerId === scope.customerId && raw.scope.warehouseId === scope.warehouseId && raw.scope.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE') for (const collection of ['balances', 'positions', 'ledger']) check(`Fresh customer raw ${collection} has exactly one original receipt row`, - Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id) && sourceMatches(raw[collection][0])) + Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id) + && id(raw[collection][0].customerId) === member.customerId && id(raw[collection][0].overseasWarehouseId) === id(warehouseId) + && (collection === 'positions' || raw[collection][0].inboundCode === member.incomingNo)) const rawBalance = raw.balances[0], rawPosition = raw.positions[0], rawMovement = raw.ledger[0] check('Raw receipt identities and quantities match the public evidence', id(rawBalance.id) === id(balance.id) && id(rawPosition.id) === id(positions[0].id) && id(rawPosition.balanceId) === id(balance.id) -- 2.54.0 From ef1eef3278007a339e75428e457c29500f4cf28b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 07:51:17 +0800 Subject: [PATCH 081/176] test(fleet): close shared trips and verify resource release --- scripts/README.md | 2 +- scripts/fleet-cross-customer-test-env.mjs | 16 +++++++++++++--- scripts/fleet-cross-customer-test-env.test.mjs | 4 +++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 7c19839e0..b0ea465c0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,6 @@ # 后端脚本管理与全量 HTTP 验收索引 -`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。 +`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`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs index 050319093..59894a155 100644 --- a/scripts/fleet-cross-customer-test-env.mjs +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -8,6 +8,7 @@ import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const ok = response => response?.http === 200 && String(response.body?.code) === '200' @@ -33,12 +34,12 @@ export function parseCrossCustomerArguments(args) { export function crossCustomerPlan(config) { return { environment: 'TEST', execute: config.execute, runId: config.runId, warehouseId: config.warehouseId || null, destinationWarehouseId: config.destinationWarehouseId || null, output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'two dispatches on one trip', - 'unready customer gate rejection', 'shared departure and replay', 'independent receipts'], + 'unready customer gate rejection', 'shared departure and replay', 'independent receipts', 'trip completion and separate resource release'], limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' } } export async function runCrossCustomer(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, - contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}) { + finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}) { const plan = crossCustomerPlan(config) if (!config.execute) return plan if (config.environment !== 'TEST' || env.CHONGCHONG_ENV !== 'test' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) @@ -107,7 +108,16 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot = const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) save('fixture.json', fixture) await verify(context, fixture, { execute: true, readInventory }) - report = { success: context.createdResources.fleetCrossCustomer?.status === 'VERIFIED' } + const trip = context.createdResources.fleetTrips?.find(row => row.tripNo === fixture.tripNo) + if (!trip) throw Error('Original fixture trip missing for resource completion') + const beforeClosure = [] + for (const member of fixture.members) beforeClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) + await finish(context, 'fleet-cross-customer-closure', trip) + const afterClosure = [] + for (const member of fixture.members) afterClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) + if (JSON.stringify(beforeClosure) !== JSON.stringify(afterClosure)) throw Error('Trip closure changed customer inventory') + report = { success: context.createdResources.fleetCrossCustomer?.status === 'VERIFIED' + && trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', closureInventoryUnchanged: true } } catch (error) { report = { success: false, error: error instanceof Error ? error.message : 'CROSS_CUSTOMER_FAILED' } } finally { diff --git a/scripts/fleet-cross-customer-test-env.test.mjs b/scripts/fleet-cross-customer-test-env.test.mjs index 7b2bad171..019839836 100644 --- a/scripts/fleet-cross-customer-test-env.test.mjs +++ b/scripts/fleet-cross-customer-test-env.test.mjs @@ -24,11 +24,13 @@ function harness(root, fail = false) { assert.equal(options.pdaToken, 'private-pda') await context.request('POST', '/api/test-owned-command', { idempotencyKey: 'stable-command', password: 'private-pda-password' }) if (fail) throw Error('FIXTURE_FAILED') - return { pdaToken: options.pdaToken, marker: context.marker } + return { pdaToken: options.pdaToken, marker: context.marker, tripNo: 'TR-OFFLINE', members: [{ customerId: '42' }, { customerId: '43' }] } }, verify: async (_context, _fixture, options) => { await options.readInventory({ marker: context.marker, customerId: '42', warehouseId: '900' }) context.createdResources.fleetCrossCustomer = { status: 'VERIFIED' } + context.createdResources.fleetTrips = [{ tripNo: 'TR-OFFLINE' }] }, + finish: async (_context, _module, trip) => { trip.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, } } } test('plan is network-free and execution requires explicit TEST, unique run and warehouse', async () => { -- 2.54.0 From 51bf4e5558b8f294b37ba5c3644af37fb7172c92 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 08:13:04 +0800 Subject: [PATCH 082/176] test(fba): verify signed delivery ACK loss and original event recovery --- scripts/README.md | 6 +- .../phase5-root-fulfillment-contract.test.mjs | 3 +- .../phase5-root-fulfillment.mjs | 52 ++++++- scripts/event-delivery-fault-proxy.py | 10 +- scripts/event-delivery-fault-proxy.test.mjs | 44 +++++- scripts/fba-core-regression-test-env.mjs | 66 +++++++-- scripts/fba-core-regression-test-env.test.mjs | 39 +++++ scripts/test-delivery-fault-proof.mjs | 76 ++++++++++ scripts/test-event-fault-executor.mjs | 26 ++-- scripts/test-event-fault-executor.test.mjs | 137 +++++++++++++++++- scripts/test-event-fault-runtime.py | 97 +++++++++---- 11 files changed, 499 insertions(+), 57 deletions(-) create mode 100644 scripts/test-delivery-fault-proof.mjs diff --git a/scripts/README.md b/scripts/README.md index b0ea465c0..d2e1cd427 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +`test-delivery-fault-proof.mjs` 是签收回写故障的内部证据校验器,无CLI、网络或写入。按精确客户/派送/来源入仓/来源订单/根订单核对原事件及载荷摘要、13次投递与代理记录、唯一收件箱和人工重试审计,并比较重试前后的Firstleg与Order事实。首片签收仍有有效余量时允许根单继续等待;不把根单必须立即COMPLETED当成成功条件。 + +F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。 + `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`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 @@ -22,7 +26,7 @@ Manifest 使用 `version:1, scope, revision, requirements:[{id,domain,level,chec 离线回归:`node --test scripts/acceptance-evidence-audit.test.mjs`。命令失败返回1,参数/文件错误返回2;报告写入调用者指定的新路径。 -`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 +`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id`、`--container-booking-id` 或 `--delivery-id`;派送选择器只读取正式 `payload.deliveryOrderId`,不接受 `deliveryId` 载荷别名;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 95ebf2c33..faf894e84 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -290,8 +290,9 @@ test('complete orchestrator calls actual source chain and blocks missing Outbox for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch', 'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`)) for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption', - 'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete']) + 'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','withDeliveryEventRecovery']) assert.match(source,new RegExp(`await ${name}\\(`)) + assert.match(source,/\(\) => saveReceiptAndComplete\(context, deliveries\[0\], customerId, index\)/) assert.match(source,/rootOrderId,\s*sourceOrderIds:\[rootOrderId,childOrderId\]/) assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/) assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 440cf455f..5d16542fa 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -5,6 +5,8 @@ import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { isDeepStrictEqual } from 'node:util' +import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' @@ -432,7 +434,10 @@ async function sendFragment(context, producer, picker, merchantToken, index) { query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) - const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) + const completed = await withDeliveryEventRecovery(context, { + marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id), + sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId), + }, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index)) check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) const tripClosure = await finishFleetTrip(context, MODULE, tripProof) @@ -440,6 +445,51 @@ async function sendFragment(context, producer, picker, merchantToken, index) { receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } } +/** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */ +export async function withDeliveryEventRecovery(context, faultScope, warehouseId, index, sign) { + const executor = context.context.f40DeliveryEventFaultExecutor + if (!executor || index !== 0) return sign() + const proof = context.createdResources.phase5RootFulfillment + if (proof.deliveryEventRecovery) throw Error('F40 delivery recovery already attempted') + const reader = context.context.f40ReadOnlyInventoryEvidence + if (typeof reader !== 'function') throw Error('F40 complete inventory evidence reader required') + const record = proof.deliveryEventRecovery = { scope: faultScope, status: 'PREPARING', restored: false } + const inventoryScope = { marker: faultScope.marker, customerId: faultScope.customerId, warehouseId } + try { + record.inventoryBefore = await reader(inventoryScope) + record.preparationAttempted = true + await executor.prepare(faultScope) + record.status = 'SIGNING' + const result = await sign() + record.status = 'RECOVERING' + record.proof = await executor.readProof(faultScope) + assertTestDeliveryFaultProof(record.proof, faultScope) + if (record.proof.restored !== true) throw Error('F40 delivery producer restoration missing') + record.inventoryAfter = await reader(inventoryScope) + if (record.inventoryBefore?.scope?.coverage !== 'CUSTOMER_WAREHOUSE_COMPLETE' + || record.inventoryBefore.scope.customerId !== faultScope.customerId + || record.inventoryBefore.scope.warehouseId !== warehouseId + || !['balances','positions','ledger'].every(key => Array.isArray(record.inventoryBefore[key])) + || !isDeepStrictEqual(record.inventoryBefore, record.inventoryAfter)) throw Error('F40 full inventory drift during delivery recovery') + record.inventoryUnchanged = true + record.status = 'VERIFIED' + return result + } catch (error) { + record.status = 'FAILED' + throw error + } finally { + if (record.preparationAttempted) try { + record.restoration = await executor.cleanup() + record.restored = record.restoration?.restored === true && record.restoration?.healthy === true && record.restoration?.proxyStopped === true + if (!record.restored) throw Error('F40 delivery restoration incomplete') + } catch (error) { + record.status = 'RESTORATION_FAILED' + record.restored = false + throw error + } + } +} + /** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ export async function run(context) { try { return await runRootFulfillment(context) } diff --git a/scripts/event-delivery-fault-proxy.py b/scripts/event-delivery-fault-proxy.py index 7bd8fa203..28d18ce37 100644 --- a/scripts/event-delivery-fault-proxy.py +++ b/scripts/event-delivery-fault-proxy.py @@ -32,11 +32,12 @@ def encoded(value): class State: - def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None): + def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None, delivery_id=None): self.event_type = event_type self.selector_id = event_id self.plan_id = loading_plan_id self.booking_id = container_booking_id + self.delivery_id = delivery_id self.log_path = log_path self.lock = threading.Lock() self.mode = "PASS" @@ -70,6 +71,8 @@ class State: selected = event_id == self.selector_id elif self.booking_id: selected = isinstance(payload, dict) and str(payload.get("sourceContainerBookingId")) == self.booking_id + elif self.delivery_id: + selected = isinstance(payload, dict) and str(payload.get("deliveryOrderId")) == self.delivery_id else: selected = isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id qualifies = event.get("eventType") == self.event_type and selected @@ -260,6 +263,7 @@ def main(): selector.add_argument("--event-id") selector.add_argument("--loading-plan-id") selector.add_argument("--container-booking-id") + selector.add_argument("--delivery-id") parser.add_argument("--bind", default="127.0.0.1") parser.add_argument("--port", type=int, default=0) parser.add_argument("--control-port", type=int, default=0) @@ -278,9 +282,11 @@ def main(): parser.error("loading-plan-id must be a positive decimal identifier") if args.container_booking_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.container_booking_id): parser.error("container-booking-id must be a positive decimal identifier") + if args.delivery_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.delivery_id): + parser.error("delivery-id must be a positive decimal identifier") if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120: parser.error("invalid event type or timeout") - state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id) + state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id, args.delivery_id) data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout)) control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token)) for server in (data, control): diff --git a/scripts/event-delivery-fault-proxy.test.mjs b/scripts/event-delivery-fault-proxy.test.mjs index 5d1dde42b..bd149224b 100644 --- a/scripts/event-delivery-fault-proxy.test.mjs +++ b/scripts/event-delivery-fault-proxy.test.mjs @@ -3,7 +3,7 @@ import { spawn, spawnSync } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, dirname, basename, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' @@ -78,6 +78,8 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], child.kill(); await exited } await close(consumer) + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + assert.ok(basename(directory).startsWith('event-fault-offline-')) await rm(directory, { recursive: true }) }, } @@ -218,3 +220,43 @@ test('booking selector rejects malformed identities and mixed selection before s assert.ok(!result.stdout.includes('"ready"')) } }) + +test('delivery selector isolates the first exact delivery event and preserves its ACK-loss recovery', async t => { + const deliveryId = '2100000000000000088' + const proxy = await fixture(['--delivery-id', deliveryId], 'OverseasDeliveryCompleted') + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const signed = { ...event(), eventType: 'OverseasDeliveryCompleted', + payload: { deliveryOrderId: deliveryId, sourceInboundId: '2100000000000000099' } } + for (const unrelated of [ + { ...signed, eventId: otherId, payload: { deliveryOrderId: '2100000000000000089' } }, + { ...signed, eventId: otherId, payload: { deliveryId } }, + { ...signed, eventId: otherId, payload: { loadingPlanId: deliveryId } }, + { ...signed, eventId: otherId, eventType: 'OverseasContainerUnloaded' }, + ]) assert.equal((await proxy.send(unrelated)).status, 200) + assert.equal((await proxy.send(signed)).status, 503) + assert.equal((await proxy.send(signed)).status, 503) + assert.equal((await proxy.send({ ...signed, eventId: samePlanOtherId })).status, 200) + const calls = proxy.calls.length + assert.equal((await proxy.send({ ...signed, payload: { deliveryOrderId: '2100000000000000089' } })).status, 409) + assert.equal(proxy.calls.length, calls) + await proxy.control('/mode', { mode: 'PASS' }) + const recovered = await proxy.send(signed) + assert.equal(recovered.status, 200) + assert.equal(JSON.parse(recovered.raw).data.outcome, 'DUPLICATE') + const status = await (await proxy.control('/status')).json() + assert.equal(status.eventId, targetId) + assert.deepEqual(status.records.filter(row => row.errorCode !== 'TARGET_EVENT_HASH_CONFLICT').map(row => row.outcome), ['APPLIED', 'DUPLICATE', 'DUPLICATE']) + assert.ok(!(await readFile(proxy.log, 'utf8')).includes('sourceInboundId')) +}) + +test('delivery selection rejects malformed IDs or a second selector before opening a listener', () => { + for (const args of [['--delivery-id', '0'], ['--delivery-id', '../88'], ['--delivery-id', '88', '--event-id', targetId], + ['--delivery-id', '88', '--container-booking-id', '66'], ['--delivery-id', '88', '--loading-plan-id', '77']]) { + const result = spawnSync(process.env.PYTHON || 'python', [script, '--upstream', 'http://127.0.0.1:1/internal/events', + '--event-type', 'OverseasDeliveryCompleted', ...args], { encoding: 'utf8', timeout: 5000, + env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken } }) + assert.equal(result.status, 2) + assert.ok(!result.stdout.includes('"ready"')) + } +}) diff --git a/scripts/fba-core-regression-test-env.mjs b/scripts/fba-core-regression-test-env.mjs index 454f9abec..1f6229a6f 100644 --- a/scripts/fba-core-regression-test-env.mjs +++ b/scripts/fba-core-regression-test-env.mjs @@ -15,18 +15,26 @@ export function parseCoreArguments(argv) { const values={},flags=new Set() for(let i=0;i1)throw Error('Choose plan or execute') + if(flags.has('--plan')&&flags.has('--execute'))throw Error('Choose plan or execute') if(values['--environment'] && values['--environment']!=='TEST')throw Error('Only explicit TEST is supported') const execute=flags.has('--execute'),runId=values['--run-id']||(!execute?'plan':null) if(!runId||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(runId))throw Error('Fresh run-id is required') if(execute&&values['--environment']!=='TEST')throw Error('Execution requires --environment TEST') const taxMode=values['--tax-mode']||'BUY_DECLARATION' if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode') - return {execute,environment:'TEST',runId,taxMode,outputDir:values['--output-dir']||`reports/fba-core-${runId}`} + const signatureMode=values['--signature-mode'] + if(signatureMode&&!F40_MODES.includes(signatureMode))throw Error('Unsupported F40 signature mode') + const verifyDeliveryEventRecovery=flags.has('--verify-delivery-event-recovery') + if(verifyDeliveryEventRecovery&&!signatureMode)throw Error('Delivery fault requires one explicit signature mode') + return {execute,environment:'TEST',runId,taxMode,signatureMode,verifyDeliveryEventRecovery,outputDir:values['--output-dir']||`reports/fba-core-${runId}`} +} +function selectedModes(config) { + if(config.signatureMode&&!F40_MODES.includes(config.signatureMode))throw Error('Unsupported F40 signature mode') + return config.signatureMode?[config.signatureMode]:F40_MODES } function taxScope(taxMode) { if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode') @@ -36,13 +44,16 @@ function taxScope(taxMode) { } export function executionPlan(config) { return {environment:'TEST',execution:config.execute?'EXECUTE':'PLAN',runId:config.runId,outputDir:config.outputDir, - taxMode:config.taxMode,scope:taxScope(config.taxMode), - modes:F40_MODES,module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS, + taxMode:config.taxMode,scope:taxScope(config.taxMode),verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true, + modes:selectedModes(config),module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS, optionalEnvironmentKeys:['CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'], - behavior:'Serial fresh fixtures; stop on first failed mode. HTTP creates customers/orders/files and uses shared PDA/pallet resources. SQL is read-only; no deploy or database mutation.', - reports:['manifest.json','summary.json',...F40_MODES.map(mode=>`${mode.toLowerCase()}.json`),'evidence/*.json'], + behavior:config.verifyDeliveryEventRecovery + ?'One explicit mode, first FBA receipt only: scoped ACK loss, original DEAD retry and verified Lastleg image/configuration restoration. HTTP creates fresh fixtures; SQL is read-only. Do not overlap deployments or other shared-environment writers.' + :'Serial fresh fixtures; stop on first failed mode. HTTP creates customers/orders/files and uses shared PDA/pallet resources. SQL is read-only; no deploy or database mutation.', + reports:['manifest.json','summary.json',...selectedModes(config).map(mode=>`${mode.toLowerCase()}.json`),'evidence/*.json'], retention:'Exclusive run directory and evidence files. Interrupted lock requires operator review; no automatic cleanup or resume.', - scopeLimit:'F40 three modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'} + scopeLimit:config.verifyDeliveryEventRecovery?'Only the selected first delivery event and listed F40 mode; not all reverse events, fee-void recovery, or UI acceptance.' + :'Only the listed F40 modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'} } export function validateCoreEnvironment(env,root=repositoryRoot) { if(basename(root)!=='baiyue-firstleg'||basename(dirname(root))!=='chongchong-global')throw Error('TEST workspace required') @@ -84,9 +95,11 @@ function createAcceptanceContext(mode,config,env,gateway) { markerNamespace:createHash('sha256').update(config.runId+':'+mode).digest('hex').slice(0,12)}) return new AcceptanceRun(options) } -export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot}={}) { +export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot,faultFactory}={}) { const scopeDescription=taxScope(config.taxMode) + const modes=selectedModes(config) const gateway=validateCoreEnvironment(env) + if(config.verifyDeliveryEventRecovery&&(env.CHONGCHONG_ENV!=='test'||!config.signatureMode))throw Error('Delivery fault requires explicit TEST and one signature mode') const output=resolve(outputRoot,config.outputDir),reports=resolve(outputRoot,'reports'),rel=relative(reports,output) if(!config.execute||config.environment!=='TEST'||!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Fresh report directory inside reports required') mkdirSync(reports,{recursive:true}) @@ -94,7 +107,7 @@ export async function runFbaCore(config,{env=process.env,readers,createContext=c const lockFd=openSync(lock,'wx') const secrets=[...REQUIRED_ENV_KEYS,'CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'] .filter(key=>/PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(key)).map(key=>env[key]) - const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription} + const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription} let initialized=false const merchantBefore={username:process.env.MERCHANT_TEST_USERNAME,password:process.env.MERCHANT_TEST_PASSWORD} try { @@ -105,7 +118,7 @@ export async function runFbaCore(config,{env=process.env,readers,createContext=c save('manifest.json',executionPlan(config)) readers ||= createTestEvidenceReaders(env) await readers.preflight() - for(const mode of F40_MODES) { + for(const mode of modes) { const context=createContext(mode,config,env,gateway) let sequence=0 const evidence=async(kind,scope,read)=>{ @@ -118,17 +131,42 @@ export async function runFbaCore(config,{env=process.env,readers,createContext=c context.context.f40TaxMode=config.taxMode context.context.f40ReadOnlyOutboxEvidence=scope=>evidence('completion',scope,readers.readCompletion) context.context.phase4ReceivingEvidenceReader=scope=>evidence('receiving',scope,readers.readReceiving) + let fault, faultPreparationAttempted=false + if(config.verifyDeliveryEventRecovery) { + if(typeof readers.readFleetInventory!=='function')throw Error('Complete raw inventory reader required for delivery recovery') + const factory=faultFactory||(await import('./test-event-fault-executor.mjs')).createTestDeliveryFaultExecutor + const executor=factory(env) + fault={...executor,prepare:async scope=>{ + save(`evidence/${mode.toLowerCase()}-delivery-scope.json`,{scope,stage:'PREPARE_INTENT'}) + faultPreparationAttempted=true;return executor.prepare(scope) + }} + context.context.f40DeliveryEventFaultExecutor=fault + context.context.f40ReadOnlyInventoryEvidence=scope=>evidence('inventory',scope,readers.readFleetInventory) + } let report try {report=await context.run()} catch (error) {report={success:false,fatal:'FBA_CORE_MODE_EXCEPTION',diagnostic:safeFailure(error),summary:{failed:1},createdResources:context.createdResources}} - finally {secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password)} + finally { + secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password) + if(fault) { + try { + const restoration=await fault.cleanup() + save(`evidence/${mode.toLowerCase()}-delivery-restoration.json`,{attempted:faultPreparationAttempted,restoration:restoration??null}) + if(faultPreparationAttempted&&(restoration?.restored!==true||restoration.healthy!==true||restoration.proxyStopped!==true))throw Error('Delivery restoration unproven') + } catch(error) {report={...report,success:false,deliveryRestorationFailed:true,restorationDiagnostic:safeFailure(error)}} + } + } + if(config.verifyDeliveryEventRecovery) { + const proof=report?.createdResources?.phase5RootFulfillment?.deliveryEventRecovery + if(proof?.status!=='VERIFIED')report={...report,success:false,deliveryRecoveryUnproven:true} + } report={...report,regressionScope:{taxMode:config.taxMode,signatureOrder:mode,description:scopeDescription}} const name=`${mode.toLowerCase()}.json`;save(name,report) const passed=modePassed(report) summary.modes.push({mode,taxMode:config.taxMode,report:name,passed,summary:report.summary,evidenceFiles:sequence}) if(!passed)break } - summary.success=summary.modes.length===F40_MODES.length&&summary.modes.every(mode=>mode.passed) + summary.success=summary.modes.length===modes.length&&summary.modes.every(mode=>mode.passed) } catch (error) {summary.failure='FBA_CORE_PREFLIGHT_OR_RUN_FAILED';summary.diagnostic=safeFailure(error)} finally { summary.finishedAt=new Date().toISOString() diff --git a/scripts/fba-core-regression-test-env.test.mjs b/scripts/fba-core-regression-test-env.test.mjs index e72680bc6..ace7bca1d 100644 --- a/scripts/fba-core-regression-test-env.test.mjs +++ b/scripts/fba-core-regression-test-env.test.mjs @@ -33,6 +33,16 @@ test('tax mode is explicit, defaults to BUY and rejects aliases, duplicate and u for(const args of [['--tax-mode','REFUND'],['--tax-mode','tax_refund'],['--tax-mode',''],['--taxMode','TAX_REFUND'], ['--tax-mode','TAX_REFUND','--tax-mode','BUY_DECLARATION']])assert.throws(()=>parseCoreArguments(args)) }) + +test('explicit single signature mode runs only that mode and reports the narrowed scope', async()=>{ + const cfg=parseCoreArguments(['--execute','--environment','TEST','--run-id','single-mode','--signature-mode','ROOT_FIRST']) + assert.deepEqual(executionPlan(cfg).modes,['ROOT_FIRST']) + assert.doesNotMatch(executionPlan(cfg).reports.join(','),/child_first/) + const seen=[] + const summary=await runFbaCore(cfg,{env:env(),readers:readers(),outputRoot:root(),createContext:mode=>({context:{},async run(){seen.push(mode);return pass()}})}) + assert.equal(summary.success,true);assert.deepEqual(seen,['ROOT_FIRST']);assert.equal(summary.modes.length,1) + for(const args of [['--signature-mode','ALL'],['--signature-mode','root_first'],['--signature-mode','ROOT_FIRST','--signature-mode','CHILD_FIRST']])assert.throws(()=>parseCoreArguments(args)) +}) test('both tax modes pass unchanged to all three existing contexts and persist on all report surfaces',async()=>{ for(const taxMode of ['BUY_DECLARATION','TAX_REFUND']){ const outputRoot=root(),cfg={...config(),taxMode},seen=[] @@ -119,3 +129,32 @@ test('reports redact dynamically created account secrets and environment/token v assert.equal(value.password,'[REDACTED]');assert.equal(value.pdaToken,'[REDACTED]') assert.equal(value.message,'failure [REDACTED]');assert.equal(value.nested.value,'safe') }) + +test('delivery fault requires one explicit mode and verified proof plus restoration, even after caught run failures', async()=>{ + assert.throws(()=>parseCoreArguments(['--plan','--verify-delivery-event-recovery'])) + const cfg=parseCoreArguments(['--execute','--environment','TEST','--run-id','fault-one','--signature-mode','ROOT_FIRST','--verify-delivery-event-recovery']) + assert.equal(executionPlan(cfg).verifyDeliveryEventRecovery,true) + for(const failure of ['none','context','proof','restore']) { + const outputRoot=root();let cleanups=0,prepares=0 + const r={...readers(),readFleetInventory:async scope=>({scope,balances:[],positions:[],ledger:[]})} + const summary=await runFbaCore(cfg,{env:{...env(),CHONGCHONG_ENV:'test'},readers:r,outputRoot, + faultFactory:()=>({prepare:async()=>{prepares++},cleanup:async()=>{ + cleanups++;if(failure==='restore')throw Error('private failure') + return {restored:true,healthy:true,proxyStopped:true} + }}),createContext:()=>({context:{},createdResources:{},async run(){ + await this.context.f40DeliveryEventFaultExecutor.prepare({marker:'F40',customerId:'90',deliveryId:'91'}) + await this.context.f40ReadOnlyInventoryEvidence({marker:'F40',customerId:'90',warehouseId:'92'}) + if(failure==='context')throw Error('private failure') + const report=pass() + if(failure!=='proof')report.createdResources.phase5RootFulfillment.deliveryEventRecovery={status:'VERIFIED'} + return report + }})}) + assert.equal(summary.success,failure==='none') + assert.equal(cleanups,1);assert.equal(prepares,1) + const evidencePath=join(outputRoot,cfg.outputDir,'evidence') + assert.ok(existsSync(join(evidencePath,'root_first-delivery-scope.json'))) + assert.ok(existsSync(join(evidencePath,'root_first-0001-inventory.json'))) + const report=JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,'root_first.json'),'utf8')) + if(failure==='restore')assert.equal(report.deliveryRestorationFailed,true) + } +}) diff --git a/scripts/test-delivery-fault-proof.mjs b/scripts/test-delivery-fault-proof.mjs new file mode 100644 index 000000000..5ceb5a71f --- /dev/null +++ b/scripts/test-delivery-fault-proof.mjs @@ -0,0 +1,76 @@ +import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' + +export const deliveryFaultScopeKeys = ['marker','customerId','deliveryId','sourceInboundId','sourceOrderId','rootOrderId'] +const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value +export function assertTestDeliveryFaultProof(proof, scope) { + const need = (value, code) => { if (!value) throw Error('Invalid delivery fault proof: ' + code) } + need(proof?.readOnly === true && deliveryFaultScopeKeys.every(key => proof[key] === scope[key]), 'SCOPE') + const row=proof.producer, before=proof.beforeRetry, after=proof.afterRetry, env=row?.envelope, payload=env?.payload + need(row && /^[1-9][0-9]{0,19}$/.test(row.entryId) && /^(?:[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26})$/.test(row.eventId), 'IDENTITY') + need(row.eventType==='OverseasDeliveryCompleted' && row.customerId===scope.customerId && row.aggregateType==='INBOUND_ORDER' + && row.aggregateId===scope.sourceInboundId && payload?.deliveryOrderId===scope.deliveryId + && payload.sourceInboundId===scope.sourceInboundId && payload.sourceOrderId===scope.sourceOrderId + && payload.rootOrderId===scope.rootOrderId && payload.deliveryNo && payload.inboundCode && payload.sourceOrderNo + && payload.fbaWarehouseCode && payload.lastlegOrderId && payload.lastlegOrderNo && Number.isFinite(Date.parse(payload.completedAt)) + && row.eventKey===`OverseasDeliveryCompleted:${payload.deliveryNo}:${scope.sourceInboundId}`, 'PRODUCER') + need(env.eventId===row.eventId && env.eventType===row.eventType && env.tenantId===scope.customerId + && Number(env.eventVersion)===1 && env.aggregateType==='INBOUND_ORDER' && env.aggregateId===scope.sourceInboundId + && Number(env.aggregateVersion)>0, 'ENVELOPE') + const hash='sha256:'+createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex') + need(row.payloadHash===hash && env.payloadHash===hash, 'HASH') + need(before?.publishStatus==='DEAD' && before.failedAttempts===12 && row.publishStatus==='PUBLISHED' + && after?.publishStatus==='PUBLISHED' && [before,after].every(value => value.entryId===row.entryId + && value.eventId===row.eventId && value.payloadHash===hash && isDeepStrictEqual(value.envelope,env)), 'ORIGINAL_RETRY') + const inbox=proof.consumer + need(inbox?.count===1 && inbox.eventId===row.eventId && inbox.eventType===row.eventType && inbox.aggregateId===scope.sourceInboundId + && inbox.status==='PROCESSED' && inbox.payloadHash===hash && isDeepStrictEqual(inbox.payload,payload), 'INBOX') + const attempts=proof.attempts, matches=proof.proxyRecords?.filter(value=>value.matched===true) + need(Array.isArray(attempts) && attempts.length===13 && new Set(attempts.map(value=>value.attemptId)).size===13 + && attempts.every((value,index)=>value.entryId===row.entryId && value.eventId===row.eventId && value.serviceName==='lastleg' + && value.outcome===(index<11?'RETRY':index===11?'DEAD':'DUPLICATE')), 'ATTEMPTS') + need(matches?.length===13 && matches.every((value,index)=>value.eventId===row.eventId && value.payloadHash===hash + && value.attempt===index+1 && value.outcome===(index===0?'APPLIED':'DUPLICATE') + && (index<12?value.errorCode==='ACK_LOSS_INJECTED':value.errorCode===null)), 'REAL_ACK_LOSS') + const audit=proof.retryAudit + need(proof.retryAuditCount===1 && audit?.requestId===proof.retryRequestId && audit.entryId===row.entryId + && audit.eventId===row.eventId && audit.payloadHash===hash && audit.previousFailures===12, 'RETRY_AUDIT') + const facts=before.business + need(facts && isDeepStrictEqual(facts,after.business), 'BUSINESS_UNCHANGED') + for (const key of ['inbound','inboundHistory','inbox','order','orderHistory','completionEvent']) need(Array.isArray(facts[key]), 'BUSINESS_SHAPE') + need(facts.inbox.length===1 && facts.inbox[0].eventId===row.eventId && facts.inbox[0].status==='PROCESSED' + && facts.inbox[0].payloadHash===hash && facts.inbox[0].processedAt && isDeepStrictEqual(facts.inbox[0].payload,payload), 'PERSISTED_INBOX') + const inbounds=facts.inbound.filter(value=>value.id===scope.sourceInboundId) + need(inbounds.length===1 && inbounds[0].customerId===scope.customerId && inbounds[0].orderId===scope.sourceOrderId + && inbounds[0].orderNo===payload.sourceOrderNo && inbounds[0].inboundCode===payload.inboundCode + && inbounds[0].processStatus==='DELIVERED' && Number.isInteger(inbounds[0].version), 'INBOUND') + need(facts.inboundHistory.filter(value=>value.inboundId===scope.sourceInboundId && value.action==='COMPLETE_DELIVERY' + && value.fromStatus==='LOADED' && value.toStatus==='DELIVERED').length===1, 'INBOUND_HISTORY') + const instant=payload.completedAt.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/) + const completedMillis=instant?Date.parse(instant[1]+'Z')+Math.floor((Number((instant[2]||'').padEnd(9,'0'))+500000)/1000000):NaN + const storedMillis=value=>Date.parse(String(value).replace(' ','T').replace(/Z$/,'')+'Z') + need(facts.inboundHistory.filter(value=>value.action==='COMPLETE_DELIVERY') + .every(value=>storedMillis(value.occurredAt)===completedMillis), 'COMPLETION_TIME') + need(facts.order.length>0 && new Set(facts.order.map(value=>value.id)).size===facts.order.length + && facts.order.every(value=>value.customerId===scope.customerId && value.rootOrderId===scope.rootOrderId && Number.isInteger(value.version)), 'ORDER_SCOPE') + const source=facts.order.find(value=>value.id===scope.sourceOrderId), root=facts.order.find(value=>value.id===scope.rootOrderId) + need(source && root && root.orderRole==='MASTER' && source.orderNo===payload.sourceOrderNo && source.inboundCode===payload.inboundCode, 'ORDER_IDENTITY') + const historyCount=(id,action)=>facts.orderHistory.filter(value=>value.orderId===id && value.action===action).length + need(historyCount(source.id,'OVERSEAS_DELIVERED')===1, 'SOURCE_SIGNED') + need(facts.orderHistory.filter(value=>value.orderId===source.id && value.action==='OVERSEAS_DELIVERED') + .every(value=>storedMillis(value.occurredAt)===completedMillis), 'SOURCE_SIGNED_TIME') + const rootCanComplete=historyCount(root.id,'OVERSEAS_DELIVERED')===1 + && facts.order.filter(value=>value.id!==root.id && value.status!=='CANCELLED').every(value=>value.status==='COMPLETED') + need(rootCanComplete ? root.status==='COMPLETED' : ['APPROVED','ACCEPTED','PROCESSING'].includes(root.status), 'ROOT_GATE') + need(source.id===root.id || source.status==='COMPLETED', 'SOURCE_COMPLETION') + need(source.status==='COMPLETED'?inbounds[0].orderStatusSnapshot==='COMPLETED':inbounds[0].orderStatusSnapshot!=='COMPLETED', 'INBOUND_ORDER_SNAPSHOT') + for (const order of facts.order) { + const events=facts.completionEvent.filter(value=>value.orderId===order.id) + need(events.length===(order.status==='COMPLETED'?1:0) && events.every(value=>value.status==='PUBLISHED' + && value.eventType==='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'), 'COMPLETION_EVENTS') + if(order.status==='COMPLETED') need(order.completedAt && historyCount(order.id,order.id===root.id?'FULFILLMENT_COMPLETED':'OVERSEAS_DELIVERED')===1, 'COMPLETION_HISTORY') + } + need(facts.completionEvent.every(value=>facts.order.some(order=>order.id===value.orderId)), 'FOREIGN_EVENT') + return proof +} diff --git a/scripts/test-event-fault-executor.mjs b/scripts/test-event-fault-executor.mjs index d808040e1..2f587be62 100644 --- a/scripts/test-event-fault-executor.mjs +++ b/scripts/test-event-fault-executor.mjs @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' +import { assertTestDeliveryFaultProof, deliveryFaultScopeKeys } from './test-delivery-fault-proof.mjs' +export { assertTestDeliveryFaultProof } from './test-delivery-fault-proof.mjs' import { validateEvidenceConfig } from './fba-core-regression-evidence.mjs' const runtime = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url), 'utf8') @@ -17,11 +19,11 @@ export function renderTestUnloadFaultAction(action, scope, agent) { return renderAction(action,scope,agent,'UNLOAD') } function renderAction(action,scope,agent,direction) { - const keys=direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId'] + const keys=direction==='DELIVERY'?deliveryFaultScopeKeys:direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId'] if (!['prepare', 'activate', 'proxy-ready', 'health', 'status', 'recover', 'proof', 'restore', 'finish'].includes(action) || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent) || !/^[A-Za-z0-9_-]{1,96}$/.test(scope?.marker || '') || keys.filter(key=>key!=='marker').some(key => !/^[1-9][0-9]{0,19}$/.test(scope?.[key] || '')) - || (direction==='UNLOAD'&&Object.keys(scope).some(key=>!keys.includes(key)))) { + || (direction!=='LOADING'&&Object.keys(scope).some(key=>!keys.includes(key)))) { throw Error('Invalid TEST fault action or scope') } const config = encode(JSON.stringify({ action, scope, direction, ...(action === 'prepare' ? { proxySource } : {}) })) @@ -32,6 +34,9 @@ function renderAction(action,scope,agent,direction) { export function createTestFaultExecutor(env, fetcher = globalThis.fetch) { return createExecutor(env,fetcher,'LOADING') } +export function createTestDeliveryFaultExecutor(env, fetcher = globalThis.fetch) { + return createExecutor(env,fetcher,'DELIVERY') +} export function createTestUnloadFaultExecutor(env, fetcher = globalThis.fetch) { return createExecutor(env,fetcher,'UNLOAD') } @@ -117,23 +122,23 @@ function createExecutor(env,fetcher,direction) { await action('restore') await wait('health', row => row.healthy) restoration=await action('finish') - if(direction==='UNLOAD'&&!(/^sha256:[0-9a-f]{64}$/.test(preparedEvidence?.originalImageId||'') + if(direction!=='LOADING'&&!(/^sha256:[0-9a-f]{64}$/.test(preparedEvidence?.originalImageId||'') &&/^[0-9a-f]{64}$/.test(preparedEvidence?.originalFingerprint||'') &&restoration?.restored===true&&restoration.healthy===true&&restoration.proxyStopped===true &&restoration.producerName==='firstleg-test-lastleg'&&restoration.originalImageId===preparedEvidence?.originalImageId &&restoration.currentImageId===preparedEvidence?.originalImageId&&restoration.originalFingerprint===preparedEvidence?.originalFingerprint - &&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error('Unload restoration evidence mismatch') + &&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error((direction==='DELIVERY'?'Delivery':'Unload')+' restoration evidence mismatch') finished = true return restoration } return { async prepare(next) { if (scope) { - if(direction==='UNLOAD'&&!isDeepStrictEqual(scope,next))throw Error('TEST unload fault scope drift') + if(direction!=='LOADING'&&!isDeepStrictEqual(scope,next))throw Error('TEST unload fault scope drift') return // Pin only the first actual current-run plan; other events pass through. } - if(direction==='UNLOAD')renderAction('prepare',next,env.FBA_CORE_JENKINS_AGENT,direction) - scope = direction==='UNLOAD'?{...next}:{ marker: next.marker, customerId: next.customerId, + if(direction!=='LOADING')renderAction('prepare',next,env.FBA_CORE_JENKINS_AGENT,direction) + scope = direction!=='LOADING'?{...next}:{ marker: next.marker, customerId: next.customerId, loadingPlanId: next.loadingPlanId, containerBookingId: next.containerBookingId } preparedEvidence=await action('prepare') await wait('proxy-ready', row => row.ready, 10, true) @@ -141,16 +146,17 @@ function createExecutor(env,fetcher,direction) { await wait('health', row => row.healthy) }, async readProof(expected) { - if (!scope || (direction==='UNLOAD'?!isDeepStrictEqual(scope,expected):Object.keys(scope).some(key => expected?.[key] !== scope[key]))) throw Error('TEST fault scope drift') + if (!scope || (direction!=='LOADING'?!isDeepStrictEqual(scope,expected):Object.keys(scope).some(key => expected?.[key] !== scope[key]))) throw Error('TEST fault scope drift') try { await wait('status', row => row.producer?.publishStatus === 'DEAD' && row.producer.failedAttempts === 12, 180) await action('recover') await wait('status', row => row.producer?.publishStatus === 'PUBLISHED', 90) const proof = await action('proof') if(direction==='UNLOAD')assertTestUnloadFaultProof(proof,scope) + if(direction==='DELIVERY')assertTestDeliveryFaultProof(proof,scope) await cleanup() - return { ...proof, restored: true, ...(direction==='UNLOAD'?{restoration}:{}) } - } finally { if(direction==='UNLOAD')await cleanup() } + return { ...proof, restored: true, ...(direction!=='LOADING'?{restoration}:{}) } + } finally { if(direction!=='LOADING')await cleanup() } }, cleanup, } diff --git a/scripts/test-event-fault-executor.test.mjs b/scripts/test-event-fault-executor.test.mjs index 3da9b468c..5b0df0ed4 100644 --- a/scripts/test-event-fault-executor.test.mjs +++ b/scripts/test-event-fault-executor.test.mjs @@ -2,7 +2,8 @@ import assert from 'node:assert/strict' import test from 'node:test' import { createHash } from 'node:crypto' import { spawnSync } from 'node:child_process' -import { createTestFaultExecutor, createTestUnloadFaultExecutor, assertTestUnloadFaultProof, renderTestFaultAction } from './test-event-fault-executor.mjs' +import { createTestFaultExecutor, createTestUnloadFaultExecutor, createTestDeliveryFaultExecutor, assertTestDeliveryFaultProof, assertTestUnloadFaultProof, renderTestFaultAction } from './test-event-fault-executor.mjs' +import { withDeliveryEventRecovery } from './admin-test-env-modules/phase5-root-fulfillment.mjs' const scope = { marker: 'TEST-ROOT', customerId: '7', loadingPlanId: '99', containerBookingId: '66' } const environment = { CHONGCHONG_ENV: 'test', JENKINS_URL: 'https://jenkins.example.invalid/', @@ -219,3 +220,137 @@ test('failed activation retains task ownership for explicit restoration, never r assert.equal(calls.filter(name => name === 'activate').length, 1) assert.deepEqual(calls.slice(-3), ['restore', 'health', 'finish']) }) + +const deliveryScope={marker:'TEST-DELIVERY',customerId:'7',deliveryId:'90',sourceInboundId:'200',sourceOrderId:'100',rootOrderId:'100'} +function deliveryProof(mode='ROOT_PENDING') { + const scope={...deliveryScope,...(mode==='CHILD_FIRST'?{sourceOrderId:'101',sourceInboundId:'201'}:{})} + const eventId='00000000-0000-4000-8000-000000000001', entryId='123' + const payload={deliveryOrderId:'90',deliveryNo:'DL-OFFLINE',lastlegOrderId:'300',lastlegOrderNo:'LL-OFFLINE', + sourceInboundId:scope.sourceInboundId,inboundCode:'IN-OFFLINE',sourceOrderId:scope.sourceOrderId, + sourceOrderNo:'FL-OFFLINE',rootOrderId:'100',fbaWarehouseCode:'ABE4',completedAt:'2026-10-03T01:00:00Z'} + const payloadHash='sha256:'+createHash('sha256').update(JSON.stringify(payload,Object.keys(payload).sort())).digest('hex') + const envelope={eventId,eventType:'OverseasDeliveryCompleted',eventVersion:1,tenantId:'7',aggregateType:'INBOUND_ORDER',aggregateId:scope.sourceInboundId,aggregateVersion:5,payloadHash,payload} + const producer={entryId,eventId,eventType:envelope.eventType,customerId:'7',aggregateId:scope.sourceInboundId,aggregateType:'INBOUND_ORDER',eventKey:`OverseasDeliveryCompleted:DL-OFFLINE:${scope.sourceInboundId}`,envelope,payloadHash,publishStatus:'PUBLISHED'} + const ownRoot=mode!=='CHILD_FIRST', childComplete=mode!=='ROOT_PENDING', rootComplete=mode==='ALL_COMPLETE' + const orders=['100','101'].map(id=>({id,rootOrderId:'100',customerId:'7',orderRole:id==='100'?'MASTER':'CHILD', + orderNo:id===scope.sourceOrderId?'FL-OFFLINE':'OTHER',inboundCode:id===scope.sourceOrderId?'IN-OFFLINE':'OTHER', + status:(id==='100'?rootComplete:childComplete)?'COMPLETED':'APPROVED',version:3, + completedAt:(id==='100'?rootComplete:childComplete)?'2026-10-03 01:00:00':null})) + const consumer={eventId,eventType:envelope.eventType,aggregateId:scope.sourceInboundId,payloadHash,payload,status:'PROCESSED'} + const business={inbound:[{id:scope.sourceInboundId,customerId:'7',orderId:scope.sourceOrderId,orderNo:'FL-OFFLINE',inboundCode:'IN-OFFLINE',processStatus:'DELIVERED',orderStatusSnapshot:orders.find(o=>o.id===scope.sourceOrderId).status,version:2}], + inboundHistory:[{id:'41',inboundId:scope.sourceInboundId,action:'COMPLETE_DELIVERY',fromStatus:'LOADED',toStatus:'DELIVERED',occurredAt:'2026-10-03 01:00:00'}], + inbox:[{...consumer,processedAt:'2026-10-03 01:00:01'}],order:orders, + orderHistory:[...(ownRoot?[{id:'51',orderId:'100',action:'OVERSEAS_DELIVERED',occurredAt:'2026-10-03 01:00:00'}]:[]),...(childComplete?[{id:'52',orderId:'101',action:'OVERSEAS_DELIVERED',occurredAt:'2026-10-03 01:00:00'}]:[]),...(rootComplete?[{id:'53',orderId:'100',action:'FULFILLMENT_COMPLETED'}]:[])], + completionEvent:orders.filter(o=>o.status==='COMPLETED').map((o,i)=>({id:String(60+i),orderId:o.id,eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED',status:'PUBLISHED',payload:{orderId:o.id}}))} + const old=unloadProof() + return JSON.parse(JSON.stringify({...scope,readOnly:true,producer,consumer:{...consumer,count:1}, + beforeRetry:{...producer,publishStatus:'DEAD',failedAttempts:12,business},afterRetry:{...producer,business}, + attempts:old.attempts,proxyRecords:old.proxyRecords.map(p=>({...p,payloadHash})),retryAudit:{...old.retryAudit,payloadHash},retryAuditCount:1,retryRequestId:old.retryRequestId})) +} +function deliveryTransport(calls, proof=deliveryProof(), finish={}) { + let recovered=false + return async(_url,options)=>{ + const {action,direction,scope}=requestedAction(options) + assert.equal(direction,'DELIVERY');assert.deepEqual(scope,deliveryScope);calls.push(action) + if(action==='recover')recovered=true + const data=action==='prepare'?{originalImageId,originalFingerprint}:action==='proxy-ready'?{ready:true}:action==='health'?{healthy:true} + :action==='status'?{producer:{publishStatus:recovered?'PUBLISHED':'DEAD',failedAttempts:12}}:action==='proof'?proof + :action==='finish'?{restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId,currentImageId:originalImageId,originalFingerprint,currentFingerprint:originalFingerprint,...finish}:{} + return {ok:true,text:async()=> 'TEST_EVENT_JSON='+JSON.stringify({ok:true,data})} + } +} +test('DELIVERY has a separate strict scope and restores before accepting original event proof',async()=>{ + const calls=[],executor=createTestDeliveryFaultExecutor(environment,deliveryTransport(calls)) + await assert.rejects(executor.prepare({...deliveryScope,eventType:'OverseasContainerUnloaded'}),/Invalid/) + await executor.prepare(deliveryScope) + await assert.rejects(executor.prepare({...deliveryScope,deliveryId:'91'}),/scope drift/) + const proof=await executor.readProof(deliveryScope) + assert.equal(proof.restored,true);assert.equal(proof.restoration.currentImageId,originalImageId) + assert.deepEqual(calls,['prepare','proxy-ready','activate','health','status','recover','status','proof','restore','health','finish']) +}) +test('DELIVERY proof distinguishes root pending, child first and all completed with immutable original evidence',()=>{ + for(const mode of ['ROOT_PENDING','CHILD_FIRST','ALL_COMPLETE']) { + const p=deliveryProof(mode),scope={...deliveryScope,sourceOrderId:p.sourceOrderId,sourceInboundId:p.sourceInboundId} + assertTestDeliveryFaultProof(p,scope) + } + for(const mutate of [p=>p.producer.envelope.payload.deliveryId='90',p=>delete p.producer.envelope.payload.deliveryOrderId, + p=>p.producer.customerId='0',p=>p.producer.eventKey='wrong',p=>p.beforeRetry.entryId='124', + p=>p.consumer.count=2,p=>p.attempts.pop(),p=>p.proxyRecords[0].outcome='DUPLICATE', + p=>p.retryAuditCount=2,p=>p.afterRetry.business.inbound[0].version++,p=>p.afterRetry.business.order[0].version++, + p=>p.beforeRetry.business.inbox[0].status='RECEIVED',p=>p.beforeRetry.business.inboundHistory.push({...p.beforeRetry.business.inboundHistory[0]}), + p=>{p.beforeRetry.business.order[0].status=p.afterRetry.business.order[0].status='COMPLETED'}, + p=>p.beforeRetry.business.completionEvent.push({orderId:'100',status:'PUBLISHED'})]) { + const proof=deliveryProof();mutate(proof);assert.throws(()=>assertTestDeliveryFaultProof(proof,deliveryScope),/Invalid delivery/) + } +}) +test('DELIVERY evidence failure still restores and restoration drift is a blocking failure',async()=>{ + const p=deliveryProof();p.consumer.count=0 + const calls=[],executor=createTestDeliveryFaultExecutor(environment,deliveryTransport(calls,p)) + await executor.prepare(deliveryScope);await assert.rejects(executor.readProof(deliveryScope),/INBOX/) + assert.deepEqual(calls.slice(-3),['restore','health','finish']) + const broken=createTestDeliveryFaultExecutor(environment,deliveryTransport([],deliveryProof(),{proxyStopped:false})) + await broken.prepare(deliveryScope);await assert.rejects(broken.readProof(deliveryScope),/Delivery restoration/) +}) +test('F40 hook faults only first signature, captures whole inventory and always cleans failed signing',async()=>{ + const calls=[],restoration={restored:true,healthy:true,proxyStopped:true} + const inventory={scope:{...deliveryScope,warehouseId:'9',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'},balances:[],positions:[],ledger:[]} + const context={context:{f40DeliveryEventFaultExecutor:{prepare:async s=>{assert.deepEqual(s,deliveryScope);calls.push('prepare')}, + readProof:async()=>{calls.push('proof');return {...deliveryProof(),restored:true,restoration}},cleanup:async()=>{calls.push('cleanup');return restoration}}, + f40ReadOnlyInventoryEvidence:async s=>{assert.equal(s.warehouseId,'9');calls.push('inventory');return structuredClone(inventory)}},createdResources:{phase5RootFulfillment:{}}} + assert.equal(await withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{calls.push('sign');return 'signed'}),'signed') + assert.deepEqual(calls,['inventory','prepare','sign','proof','inventory','cleanup']) + assert.equal(context.createdResources.phase5RootFulfillment.deliveryEventRecovery.status,'VERIFIED') + await withDeliveryEventRecovery(context,deliveryScope,'9',1,async()=>calls.push('second')) + assert.equal(calls.at(-1),'second') + context.createdResources.phase5RootFulfillment={} + await assert.rejects(withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{throw Error('sign failed')}),/sign failed/) + assert.equal(calls.at(-1),'cleanup') + assert.equal(context.createdResources.phase5RootFulfillment.deliveryEventRecovery.restored,true) +}) +test('Python DELIVERY SQL uses actual payload, original Lastleg retry, and Order database without inventory queries',()=>{ + const result=spawnSync('python',['-c',String.raw` +import importlib.util,sys,ast,pathlib,copy +from unittest.mock import patch,MagicMock,mock_open +sys.dont_write_bytecode=True +p=pathlib.Path('scripts/test-event-fault-runtime.py');ast.parse(p.read_text(encoding='utf-8')) +spec=importlib.util.spec_from_file_location('runtime',p);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +s={'marker':'TEST-DELIVERY','customerId':'7','deliveryId':'90','sourceInboundId':'200','sourceOrderId':'100','rootOrderId':'100'} +assert m.scope_directory(s,'DELIVERY')!=m.scope_directory({'marker':s['marker'],'customerId':'7','containerBookingId':'200'},'UNLOAD') +assert m.producer_name({'direction':'DELIVERY'})=='firstleg-test-lastleg' +assert m.target_setting({'direction':'DELIVERY'})==('FIRSTLEG_SERVICE_URL','http://firstleg:9202') +queries=[] +with patch.object(m,'inspect',side_effect=lambda name:{'Name':'/'+name}),patch.object(m,'require_test'),patch.object(m,'read_sql',side_effect=lambda container,sql:queries.append((container['Name'],sql)) or []): + assert m.producer(s,'DELIVERY') is None + m.delivery_facts(s,'00000000-0000-4000-8000-000000000001') +assert [q[0] for q in queries]==['/firstleg-test-lastleg','/firstleg-test-business','/firstleg-test-order'] +assert "$.payload.deliveryOrderId" in queries[0][1] and "customer_id='7'" in queries[0][1] +assert 'fl_inbound_status_history' in queries[1][1] and 'fl_container_event_inbox' in queries[1][1] +assert 'ord_order_status_history' in queries[2][1] and 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' in queries[2][1] +assert all('inventory' not in q[1] for q in queries) +original={'Name':'/firstleg-test-lastleg','Id':'original-container','Image':'sha256:'+'a'*64, + 'Config':{'Image':'lastleg:test','Env':['FIRSTLEG_SERVICE_URL=http://firstleg:9202'],'Labels':{'com.docker.compose.project':'firstleg-test'}}, + 'HostConfig':{},'Mounts':[],'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3','Aliases':['lastleg-service']}}}, + 'State':{'Running':True,'Health':{'Status':'healthy'}}} +consumer=copy.deepcopy(original);consumer['Name']='/firstleg-test-business' +with patch.object(m,'producer',return_value=None),patch.object(m,'inspect',side_effect=lambda name:original if name=='firstleg-test-lastleg' else consumer),patch.object(m.Path,'mkdir'),patch.object(m.Path,'write_bytes'),patch('builtins.open',mock_open()),patch.object(m.socket,'socket') as sock,patch.object(m.subprocess,'Popen') as process,patch.object(m,'private_write'): + sock.return_value.getsockname.return_value=('127.0.0.1',18001) + m.prepare(s,pathlib.Path('/offline'),{'direction':'DELIVERY','proxySource':''}) + args=process.call_args.args[0] + assert args[args.index('--delivery-id')+1]=='90' and args[args.index('--event-type')+1]=='OverseasDeliveryCompleted' + assert '--container-booking-id' not in args and args[args.index('--upstream')+1].endswith(':9202/internal/firstleg/v1/container-events') +state={'direction':'DELIVERY','restored':False,'prepared':True,'image':'lastleg:test','originalId':'original-container', + 'originalImageId':original['Image'],'originalFingerprint':m.fingerprint(original),'originalBody':m.recreation(original,m.env_of(original),full=True),'proxyPort':18001} +restored=copy.deepcopy(original);restored['Id']='restored-new-id' +with patch.object(m,'control',side_effect=OSError('proxy already stopped')),patch.object(m,'inspect',return_value=restored),patch.object(m,'private_write'),patch.object(m,'replace') as replace,patch.object(m,'docker') as docker: + assert m.restore(pathlib.Path('/offline'),state)=={'restoreStarted':True} + assert state['prepared'] is False + replace.assert_not_called();docker.assert_not_called() +state['prepared']=True +restored['Config']['Env']=['FIRSTLEG_SERVICE_URL=http://firstleg:9202','UNEXPECTED=1'] +with patch.object(m,'control'),patch.object(m,'inspect',return_value=restored),patch.object(m,'replace') as replace: + try: m.restore(pathlib.Path('/offline'),state);assert False + except RuntimeError as error: assert str(error)=='CONCURRENT_PRODUCER_CHANGE' + replace.assert_not_called() +`],{encoding:'utf8',cwd:new URL('..',import.meta.url),timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) diff --git a/scripts/test-event-fault-runtime.py b/scripts/test-event-fault-runtime.py index 6bde84707..7a4662f5d 100644 --- a/scripts/test-event-fault-runtime.py +++ b/scripts/test-event-fault-runtime.py @@ -103,18 +103,18 @@ def private_write(path, value): def scope_directory(scope, direction='LOADING'): - if direction not in ('LOADING', 'UNLOAD'): + if direction not in ('LOADING', 'UNLOAD', 'DELIVERY'): raise RuntimeError('INVALID_DIRECTION') marker = scope.get('marker', '') if not re.fullmatch(r'[A-Za-z0-9_-]{1,96}', marker): raise RuntimeError('INVALID_SCOPE') - keys = ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId') - if direction == 'UNLOAD' and set(scope) != {'marker', *keys}: + keys = ('customerId', 'deliveryId', 'sourceInboundId', 'sourceOrderId', 'rootOrderId') if direction == 'DELIVERY' else ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId') + if direction != 'LOADING' and set(scope) != {'marker', *keys}: raise RuntimeError('INVALID_SCOPE') for key in keys: if not re.fullmatch(r'[1-9][0-9]{0,19}', scope.get(key, '')): raise RuntimeError('INVALID_SCOPE') - identity = 'UNLOAD:' + marker if direction == 'UNLOAD' else marker + identity = direction + ':' + marker if direction != 'LOADING' else marker return Path(tempfile.gettempdir()) / ('chongchong-event-' + hashlib.sha256(identity.encode()).hexdigest()[:24]) @@ -122,12 +122,20 @@ def unload(state): return state.get('direction', 'LOADING') == 'UNLOAD' +def delivery(state): + return state.get('direction') == 'DELIVERY' + + +def reverse_delivery(state): + return state.get('direction') in ('UNLOAD', 'DELIVERY') + + def producer_name(state): - return 'firstleg-test-lastleg' if unload(state) else 'firstleg-test-business' + return 'firstleg-test-lastleg' if reverse_delivery(state) else 'firstleg-test-business' def target_setting(state): - return ('FIRSTLEG_SERVICE_URL', 'http://firstleg:9202') if unload(state) else ('LASTLEG_SERVICE_URL', 'http://lastleg-service:9303') + return ('FIRSTLEG_SERVICE_URL', 'http://firstleg:9202') if reverse_delivery(state) else ('LASTLEG_SERVICE_URL', 'http://lastleg-service:9303') def fingerprint(container): @@ -159,7 +167,7 @@ def read_sql(container, sql): if re.search(r'\b(INSERT|UPDATE|DELETE|ALTER|DROP|TRUNCATE|CALL|INTO|OUTFILE)\b', sql, re.I): raise RuntimeError('NON_READ_ONLY_SQL') e = env_of(container) - prefix = 'FIRSTLEG' if container['Name'] == '/firstleg-test-business' else 'LASTLEG' + prefix = {'/firstleg-test-business': 'FIRSTLEG', '/firstleg-test-lastleg': 'LASTLEG', '/firstleg-test-order': 'ORDER'}[container['Name']] url = urllib.parse.urlsplit(e[prefix + '_DB_URL'].removeprefix('jdbc:')) host = '127.0.0.1' if url.hostname == 'host.docker.internal' else url.hostname transaction = 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\n' + sql + '\nCOMMIT;' @@ -174,7 +182,10 @@ def read_sql(container, sql): def producer(scope, direction='LOADING'): - if direction == 'UNLOAD': + if direction == 'DELIVERY': + rows = read_sql(inspect('firstleg-test-lastleg'), + "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='" + scope['customerId'] + "' AND event_type='OverseasDeliveryCompleted' AND JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.deliveryOrderId'))='" + scope['deliveryId'] + "';") + elif direction == 'UNLOAD': rows = read_sql(inspect('firstleg-test-lastleg'), "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='0' AND aggregate_id='" + scope['containerBookingId'] + "' AND event_type='OverseasContainerUnloaded' AND event_key='OverseasContainerUnloaded:" + scope['containerBookingId'] + "';") @@ -201,18 +212,50 @@ def business_ids(scope): return [row['id'] for row in rows] +def business_snapshot(scope, state, row): + if delivery(state): + return {'business': delivery_facts(scope, row['eventId'])} + return {'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)} + + +def delivery_facts(scope, event_id): + if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id): + raise RuntimeError('INVALID_EVENT_IDENTITY') + customer, source, root = scope['customerId'], scope['sourceOrderId'], scope['rootOrderId'] + firstleg = inspect('firstleg-test-business') + order = inspect('firstleg-test-order') + require_test(firstleg, 'firstleg-test-business') + require_test(order, 'firstleg-test-order') + # Each service is read in its own consistent read-only transaction; no cross-schema join. + inbound_sql = "tenant_id=" + customer + " AND deleted=0 AND order_id IN (" + source + "," + root + ")" + first_rows = read_sql(firstleg, + "SELECT JSON_OBJECT('kind','inbound','id',CAST(id AS CHAR),'customerId',CAST(tenant_id AS CHAR),'orderId',CAST(order_id AS CHAR),'orderNo',order_no,'inboundCode',inbound_code,'processStatus',process_status,'orderStatusSnapshot',order_status_snapshot,'version',version,'updatedAt',CAST(updated_at AS CHAR)) FROM fl_inbound_order WHERE " + inbound_sql + " ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','inboundHistory','id',CAST(id AS CHAR),'inboundId',CAST(inbound_id AS CHAR),'action',action,'fromStatus',from_status,'toStatus',to_status,'occurredAt',CAST(occurred_at AS CHAR)) FROM fl_inbound_status_history WHERE tenant_id=" + customer + " AND inbound_id=" + scope['sourceInboundId'] + " ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','inbox','eventId',event_id,'eventType',event_type,'aggregateId',aggregate_id,'aggregateVersion',aggregate_version,'payloadHash',payload_hash,'payload',JSON_EXTRACT(payload_json,'$'),'status',status,'receivedAt',CAST(received_at AS CHAR),'processedAt',CAST(processed_at AS CHAR)) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';") + tree = "tenant_id=" + customer + " AND deleted=0 AND (id=" + root + " OR root_order_id=" + root + ")" + ids = "SELECT id FROM ord_order WHERE " + tree + order_rows = read_sql(order, + "SELECT JSON_OBJECT('kind','order','id',CAST(id AS CHAR),'customerId',CAST(tenant_id AS CHAR),'rootOrderId',CAST(root_order_id AS CHAR),'orderRole',order_role,'orderNo',order_no,'inboundCode',inbound_code,'status',status,'version',version,'completedAt',CAST(completed_at AS CHAR),'updatedAt',CAST(updated_at AS CHAR)) FROM ord_order WHERE " + tree + " ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','orderHistory','id',CAST(id AS CHAR),'orderId',CAST(order_id AS CHAR),'action',action,'fromStatus',from_status,'toStatus',to_status,'occurredAt',CAST(occurred_at AS CHAR)) FROM ord_order_status_history WHERE tenant_id=" + customer + " AND order_id IN (" + ids + ") ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','completionEvent','id',CAST(id AS CHAR),'orderId',CAST(aggregate_id AS CHAR),'eventType',event_type,'status',publish_status,'payload',JSON_EXTRACT(payload_json,'$')) FROM ord_outbox_event WHERE tenant_id=" + customer + " AND aggregate_id IN (" + ids + ") AND event_type='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' ORDER BY id;") + return {kind: [row for row in first_rows + order_rows if row['kind'] == kind] for kind in + ('inbound', 'inboundHistory', 'inbox', 'order', 'orderHistory', 'completionEvent')} + + def prepare(scope, directory, config): + if delivery(config) and producer(scope, 'DELIVERY') is not None: + raise RuntimeError('DELIVERY_EVENT_ALREADY_EXISTS') directory.mkdir(mode=0o700) original = inspect(producer_name(config)) require_test(original, producer_name(config)) - consumer_name = 'firstleg-test-business' if unload(config) else 'firstleg-test-lastleg' + consumer_name = 'firstleg-test-business' if reverse_delivery(config) else 'firstleg-test-lastleg' consumer = inspect(consumer_name) require_test(consumer, consumer_name) old_env = env_of(original) setting, direct = target_setting(config) if old_env.get(setting) != direct: raise RuntimeError('PRODUCER_NOT_DIRECT') - original_body = recreation(original, old_env, full=unload(config)) + original_body = recreation(original, old_env, full=reverse_delivery(config)) token = secrets.token_urlsafe(32) # Reserve task-specific ports and let the existing proxy select exactly this plan. sockets = [socket.socket() for _ in range(2)] @@ -225,8 +268,10 @@ def prepare(scope, directory, config): proxy.write_bytes(base64.b64decode(config['proxySource'])) ip = consumer['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] log = open(directory / 'proxy-output.log', 'wb') - upstream = ':9202/internal/firstleg/v1/container-events' if unload(config) else ':9303/internal/lastleg/events' - selector = ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']] + upstream = ':9202/internal/firstleg/v1/container-events' if reverse_delivery(config) else ':9303/internal/lastleg/events' + selector = [] if delivery(config) else ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']] + if delivery(config): + selector = ['--event-type', 'OverseasDeliveryCompleted', '--delivery-id', scope['deliveryId']] process = subprocess.Popen(['python3', str(proxy), '--upstream', 'http://' + ip + upstream, *selector, '--bind', '0.0.0.0', '--port', str(ports[0]), '--control-port', str(ports[1]), '--log', str(directory / 'proxy-evidence.jsonl')], @@ -248,7 +293,7 @@ def activate(scope, directory, state): setting, direct = target_setting(state) if original['Id'] != state['originalId'] or original['Config']['Image'] != state['image'] or env_of(original).get(setting) != direct: raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') - if unload(state) and (original['Image'] != state['originalImageId'] or fingerprint(original) != state['originalFingerprint']): + if reverse_delivery(state) and (original['Image'] != state['originalImageId'] or fingerprint(original) != state['originalFingerprint']): raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') body = copy.deepcopy(state['originalBody']) e = env_of(original) @@ -270,7 +315,7 @@ def recover(scope, directory, state): pinned = control(state) if pinned['eventId'] != row['eventId'] or pinned['payloadHash'] != row['payloadHash']: raise RuntimeError('PINNED_EVENT_MISMATCH') - state['beforeRetry'] = {**row, **({'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)})} + state['beforeRetry'] = {**row, **business_snapshot(scope, state, row)} private_write(directory / 'state.json', state) control(state, 'PASS') container = inspect(producer_name(state)) @@ -278,7 +323,7 @@ def recover(scope, directory, state): ip = container['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] body = {'requestId': state['operationsRequestId'], 'operator': 'admin-full-test', 'reason': scope['marker'] + ': scoped ACK-loss recovery', 'expectedPayloadHash': row['payloadHash']} - endpoint = ':9303/internal/lastleg/event-operations/' if unload(state) else ':9202/internal/firstleg/event-operations/' + endpoint = ':9303/internal/lastleg/event-operations/' if reverse_delivery(state) else ':9202/internal/firstleg/event-operations/' url = 'http://' + ip + endpoint + row['entryId'] + '/retry' request = urllib.request.Request(url, json.dumps(body).encode(), {'X-Event-Operations-Token': e['EVENT_OPERATIONS_TOKEN'], 'Content-Type': 'application/json'}) @@ -295,7 +340,7 @@ def restore(directory, state): try: control(state, 'PASS') except Exception: - if not unload(state): + if not reverse_delivery(state): raise # A dead proxy must not prevent restoring the producer's direct target. if state['prepared']: @@ -309,8 +354,8 @@ def restore(directory, state): state['prepared'] = False private_write(directory / 'state.json', state) return {'restoreStarted': True} - if current['Id'] == state['originalId'] or (unload(state) and current['Image'] == state['originalImageId'] and fingerprint(current) == state['originalFingerprint']): - if unload(state) and not current['State'].get('Running'): + if current['Id'] == state['originalId'] or (reverse_delivery(state) and current['Image'] == state['originalImageId'] and fingerprint(current) == state['originalFingerprint']): + if reverse_delivery(state) and not current['State'].get('Running'): docker('POST', '/containers/' + current['Id'] + '/start') state['prepared'] = False private_write(directory / 'state.json', state) @@ -330,15 +375,15 @@ def proof(scope, state): event_id, entry_id = row['eventId'], row['entryId'] if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id) or not re.fullmatch(r'[1-9][0-9]{0,19}', entry_id): raise RuntimeError('INVALID_EVENT_IDENTITY') - event_type = 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted' + event_type = 'OverseasDeliveryCompleted' if delivery(state) else 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted' attempts = read_sql(inspect(producer_name(state)), "SELECT JSON_OBJECT('attemptId',attempt_id,'serviceName',service_name,'entryId',entry_id,'eventId',event_id,'targetName',target_name,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='" + entry_id + "' AND event_type='" + event_type + "' ORDER BY started_at,attempt_id;") audit = read_sql(inspect(producer_name(state)), "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE request_id='" - + state['operationsRequestId'] + "';" if not unload(state) else - "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE entry_id='" + entry_id + "' AND event_type='OverseasContainerUnloaded';") - if unload(state): + + state['operationsRequestId'] + "';" if not reverse_delivery(state) else + "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE entry_id='" + entry_id + "' AND event_type='" + event_type + "';") + if reverse_delivery(state): consumers = read_sql(inspect('firstleg-test-business'), "SELECT JSON_OBJECT('eventId',event_id,'payloadHash',payload_hash,'status',status,'eventType',event_type,'aggregateId',aggregate_id,'payload',JSON_EXTRACT(payload_json,'$')) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';") else: @@ -348,7 +393,7 @@ def proof(scope, state): consumer = {**(consumers[0] if len(consumers) == 1 else {}), 'count': len(consumers)} result = {**scope, 'readOnly': True, 'producer': row, 'consumer': consumer, 'attempts': attempts, 'proxyRecords': control(state)['records'], 'beforeRetry': state['beforeRetry'], - 'afterRetry': {**row, **({'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)})}, + 'afterRetry': {**row, **business_snapshot(scope, state, row)}, 'retryAudit': audit[0] if len(audit) == 1 else None, 'retryAuditCount': len(audit), 'retryRequestId': state['operationsRequestId'], 'restored': False} private_write(scope_directory(scope, state.get('direction', 'LOADING')) / 'proof.json', result) return result @@ -363,14 +408,14 @@ def finish(directory, state): raise RuntimeError('RESTORED_SERVICE_NOT_HEALTHY') if sorted(current['Config']['Env']) != sorted(state['originalBody']['Env']): raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH') - if unload(state) and (current['Image'] != state['originalImageId'] or fingerprint(current) != state['originalFingerprint']): + if reverse_delivery(state) and (current['Image'] != state['originalImageId'] or fingerprint(current) != state['originalFingerprint']): raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH') cmdline = Path('/proc/' + str(state['proxyPid']) + '/cmdline') if cmdline.exists(): if str(directory / 'proxy.py').encode() not in cmdline.read_bytes().split(b'\x00'): raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') os.kill(state['proxyPid'], signal.SIGTERM) - if unload(state): + if reverse_delivery(state): for _ in range(50): if not cmdline.exists() or not cmdline.read_bytes(): break @@ -378,7 +423,7 @@ def finish(directory, state): else: raise RuntimeError('PROXY_NOT_STOPPED') result = {'restored': True} - if unload(state): + if reverse_delivery(state): result.update(healthy=True, proxyStopped=True, producerName=producer_name(state), originalImageId=state['originalImageId'], currentImageId=current['Image'], originalFingerprint=state['originalFingerprint'], currentFingerprint=fingerprint(current)) -- 2.54.0 From 933dd4cdefe9bc0f9cdc441c72b23d94d5c7764b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 08:20:27 +0800 Subject: [PATCH 083/176] test(fba): retain parent proof across isolated fragment contexts --- .../phase5-root-fulfillment.mjs | 4 +++- scripts/test-event-fault-executor.test.mjs | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 5d16542fa..3cbf1b110 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -449,7 +449,8 @@ async function sendFragment(context, producer, picker, merchantToken, index) { export async function withDeliveryEventRecovery(context, faultScope, warehouseId, index, sign) { const executor = context.context.f40DeliveryEventFaultExecutor if (!executor || index !== 0) return sign() - const proof = context.createdResources.phase5RootFulfillment + const proof = context.context.f40RootFulfillmentProof + if (!proof) throw Error('F40 parent fulfillment proof required') if (proof.deliveryEventRecovery) throw Error('F40 delivery recovery already attempted') const reader = context.context.f40ReadOnlyInventoryEvidence if (typeof reader !== 'function') throw Error('F40 complete inventory evidence reader required') @@ -521,6 +522,7 @@ async function runRootFulfillment(context) { const scope = Object.create(context) scope.marker = `${context.marker}-F40` scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', + f40RootFulfillmentProof: proof, orderStopAtApproved:cancelRemainder, orderProfile: { taxMode, fulfillmentType: 'STANDARD' },f40TaxMode:taxMode,f40TaxEvidence:proof.taxCustoms, waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, diff --git a/scripts/test-event-fault-executor.test.mjs b/scripts/test-event-fault-executor.test.mjs index 5b0df0ed4..53d4c8c0c 100644 --- a/scripts/test-event-fault-executor.test.mjs +++ b/scripts/test-event-fault-executor.test.mjs @@ -294,18 +294,20 @@ test('DELIVERY evidence failure still restores and restoration drift is a blocki test('F40 hook faults only first signature, captures whole inventory and always cleans failed signing',async()=>{ const calls=[],restoration={restored:true,healthy:true,proxyStopped:true} const inventory={scope:{...deliveryScope,warehouseId:'9',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'},balances:[],positions:[],ledger:[]} - const context={context:{f40DeliveryEventFaultExecutor:{prepare:async s=>{assert.deepEqual(s,deliveryScope);calls.push('prepare')}, + const parentProof={} + const context={context:{f40RootFulfillmentProof:parentProof,f40DeliveryEventFaultExecutor:{prepare:async s=>{assert.deepEqual(s,deliveryScope);calls.push('prepare')}, readProof:async()=>{calls.push('proof');return {...deliveryProof(),restored:true,restoration}},cleanup:async()=>{calls.push('cleanup');return restoration}}, - f40ReadOnlyInventoryEvidence:async s=>{assert.equal(s.warehouseId,'9');calls.push('inventory');return structuredClone(inventory)}},createdResources:{phase5RootFulfillment:{}}} + f40ReadOnlyInventoryEvidence:async s=>{assert.equal(s.warehouseId,'9');calls.push('inventory');return structuredClone(inventory)}},createdResources:{phase3OrderSplitting:{}}} assert.equal(await withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{calls.push('sign');return 'signed'}),'signed') assert.deepEqual(calls,['inventory','prepare','sign','proof','inventory','cleanup']) - assert.equal(context.createdResources.phase5RootFulfillment.deliveryEventRecovery.status,'VERIFIED') + assert.equal(parentProof.deliveryEventRecovery.status,'VERIFIED') + assert.equal(context.createdResources.phase5RootFulfillment,undefined) await withDeliveryEventRecovery(context,deliveryScope,'9',1,async()=>calls.push('second')) assert.equal(calls.at(-1),'second') - context.createdResources.phase5RootFulfillment={} + context.context.f40RootFulfillmentProof={} await assert.rejects(withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{throw Error('sign failed')}),/sign failed/) assert.equal(calls.at(-1),'cleanup') - assert.equal(context.createdResources.phase5RootFulfillment.deliveryEventRecovery.restored,true) + assert.equal(context.context.f40RootFulfillmentProof.deliveryEventRecovery.restored,true) }) test('Python DELIVERY SQL uses actual payload, original Lastleg retry, and Order database without inventory queries',()=>{ const result=spawnSync('python',['-c',String.raw` -- 2.54.0 From 912e69aeb200811e1761130a769411b8d68ac12a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 08:54:52 +0800 Subject: [PATCH 084/176] test: isolate fee void ACK loss and preserve wallet delivery --- scripts/event-delivery-fault-proxy.py | 33 +++++++++-- scripts/event-delivery-fault-proxy.test.mjs | 62 +++++++++++++++++++-- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/scripts/event-delivery-fault-proxy.py b/scripts/event-delivery-fault-proxy.py index 28d18ce37..b853ee601 100644 --- a/scripts/event-delivery-fault-proxy.py +++ b/scripts/event-delivery-fault-proxy.py @@ -32,12 +32,15 @@ def encoded(value): class State: - def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None, delivery_id=None): + def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None, delivery_id=None, + fee_source_id=None, source_version=None): self.event_type = event_type self.selector_id = event_id self.plan_id = loading_plan_id self.booking_id = container_booking_id self.delivery_id = delivery_id + self.fee_source_id = fee_source_id + self.source_version = source_version self.log_path = log_path self.lock = threading.Lock() self.mode = "PASS" @@ -73,6 +76,9 @@ class State: selected = isinstance(payload, dict) and str(payload.get("sourceContainerBookingId")) == self.booking_id elif self.delivery_id: selected = isinstance(payload, dict) and str(payload.get("deliveryOrderId")) == self.delivery_id + elif self.fee_source_id: + selected = (isinstance(payload, dict) and str(payload.get("sourceFeeId")) == self.fee_source_id + and str(payload.get("sourceVersion")) == self.source_version and payload.get("action") == "VOIDED") else: selected = isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id qualifies = event.get("eventType") == self.event_type and selected @@ -146,16 +152,22 @@ class QuietHandler(BaseHTTPRequestHandler): def data_handler(state, upstream, timeout): parts = urllib.parse.urlsplit(upstream) endpoint = urllib.parse.urlunsplit(("", "", parts.path or "/", parts.query, "")) + # Both consumers share FINANCE_SERVICE_URL. Preserve wallet delivery while + # targeting one fee event; never select wallet bodies for fault injection. + wallet_endpoint = "/internal/finance/v1/wallet-command-events" + wallet_passthrough = (state.fee_source_id is not None + and endpoint == "/internal/finance/v1/business-fee-source-events") class DataHandler(QuietHandler): def do_POST(self): - if self.path != endpoint: + passthrough = wallet_passthrough and self.path == wallet_endpoint + if self.path != endpoint and not passthrough: self.safe_error(404, "UNEXPECTED_EVENT_ENDPOINT") return body = self.read_body() if body is None: return - identity, matched, error_code, attempt, mode = state.select(body) + identity, matched, error_code, attempt, mode = (None, False, None, 0, "PASS") if passthrough else state.select(body) if error_code: state.record(identity, matched, attempt, None, error_code) self.safe_error(409, error_code) @@ -164,7 +176,8 @@ def data_handler(state, upstream, timeout): connection_headers = set(self.headers.get("Connection", "").lower().replace(" ", "").split(",")) headers = {name: value for name, value in self.headers.items() if name.lower() not in HOP_HEADERS | connection_headers} - request = urllib.request.Request(upstream, data=body, headers=headers, method="POST") + destination = urllib.parse.urlunsplit((parts.scheme, parts.netloc, wallet_endpoint, "", "")) if passthrough else upstream + request = urllib.request.Request(destination, data=body, headers=headers, method="POST") try: # Redirects are forbidden: a redirect could expose a service identity token. class NoRedirect(urllib.request.HTTPRedirectHandler): @@ -264,6 +277,8 @@ def main(): selector.add_argument("--loading-plan-id") selector.add_argument("--container-booking-id") selector.add_argument("--delivery-id") + selector.add_argument("--fee-source-id") + parser.add_argument("--source-version") parser.add_argument("--bind", default="127.0.0.1") parser.add_argument("--port", type=int, default=0) parser.add_argument("--control-port", type=int, default=0) @@ -284,9 +299,17 @@ def main(): parser.error("container-booking-id must be a positive decimal identifier") if args.delivery_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.delivery_id): parser.error("delivery-id must be a positive decimal identifier") + if bool(args.fee_source_id) != bool(args.source_version): + parser.error("fee-source-id and source-version are required together") + if args.fee_source_id and (not re.fullmatch(r"[1-9][0-9]{0,19}", args.fee_source_id) + or not re.fullmatch(r"[1-9][0-9]{0,9}", args.source_version) + or int(args.source_version) > 2147483647 + or args.event_type != "BusinessFeeSourceChanged"): + parser.error("fee void requires valid source identity, version and BusinessFeeSourceChanged") if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120: parser.error("invalid event type or timeout") - state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id, args.delivery_id) + state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id, args.delivery_id, + args.fee_source_id, args.source_version) data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout)) control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token)) for server in (data, control): diff --git a/scripts/event-delivery-fault-proxy.test.mjs b/scripts/event-delivery-fault-proxy.test.mjs index bd149224b..0d3480724 100644 --- a/scripts/event-delivery-fault-proxy.test.mjs +++ b/scripts/event-delivery-fault-proxy.test.mjs @@ -19,7 +19,7 @@ const event = (id = targetId, planId = '2100000000000000001') => ({ eventId: id, payload: { loadingPlanId: planId, syntheticPrivateField: secret } }) const close = server => new Promise(resolve => server.close(resolve)) -async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], eventType = 'LoadingCompleted') { +async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], eventType = 'LoadingCompleted', endpoint = '/internal/events') { const calls = [] const applied = new Set() let invalidAck = false @@ -28,7 +28,7 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], for await (const chunk of request) chunks.push(chunk) const raw = Buffer.concat(chunks).toString() const envelope = JSON.parse(raw) - calls.push({ raw, authorization: request.headers.authorization, eventId: envelope.eventId }) + calls.push({ raw, authorization: request.headers.authorization, eventId: envelope.eventId, path: request.url }) const outcome = applied.has(envelope.eventId) ? 'DUPLICATE' : 'APPLIED' applied.add(envelope.eventId) const body = JSON.stringify({ code: '200', data: { eventId: invalidAck ? otherId : envelope.eventId, @@ -40,7 +40,7 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], const directory = await mkdtemp(join(tmpdir(), 'event-fault-offline-')) const log = join(directory, 'evidence.jsonl') const child = spawn(process.env.PYTHON || 'python', [script, '--upstream', - `http://127.0.0.1:${consumer.address().port}/internal/events`, '--event-type', eventType, + `http://127.0.0.1:${consumer.address().port}${endpoint}`, '--event-type', eventType, ...selector, '--log', log], { env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken }, stdio: ['ignore', 'pipe', 'pipe'] }) let output = ''; let errors = '' @@ -58,12 +58,12 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], child.once('error', error => { clearTimeout(timer); reject(error) }) child.once('exit', code => { if (!output) { clearTimeout(timer); reject(new Error(`Proxy exit ${code}`)) } }) }) - const dataUrl = `http://127.0.0.1:${ready.dataPort}/internal/events` + const dataUrl = `http://127.0.0.1:${ready.dataPort}${endpoint}` const controlUrl = `http://127.0.0.1:${ready.controlPort}` return { calls, applied, log, invalidAck: value => { invalidAck = value }, - send: async envelope => { + send: async (envelope, path = endpoint) => { const sent = JSON.stringify(envelope) - const response = await fetch(dataUrl, { method: 'POST', body: sent, + const response = await fetch(new URL(path, dataUrl), { method: 'POST', body: sent, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` } }) return { status: response.status, raw: await response.text(), headers: response.headers, sent } }, @@ -85,6 +85,26 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], } } +test('fee fault preserves wallet endpoint bytes and auth without selecting its body', async t => { + const proxy = await fixture(['--fee-source-id', '2100000000000000001', '--source-version', '1'], + 'BusinessFeeSourceChanged', '/internal/finance/v1/business-fee-source-events') + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const envelope = { ...event(), eventType: 'BusinessFeeSourceChanged', + payload: { sourceFeeId: '2100000000000000001', sourceVersion: 1, action: 'VOIDED' } } + const walletPath = '/internal/finance/v1/wallet-command-events' + const wallet = await proxy.send(envelope, walletPath) + assert.equal(wallet.status, 200) + assert.equal(proxy.calls[0].path, walletPath) + assert.equal(proxy.calls[0].raw, wallet.sent) + assert.equal(proxy.calls[0].authorization, `Bearer ${secret}`) + assert.equal((await (await proxy.control('/status')).json()).attempt, 0) + assert.equal((await proxy.send(envelope, '/internal/finance/v1/unknown')).status, 404) + assert.equal(proxy.calls.length, 1) + assert.equal((await proxy.send(envelope)).status, 503) + assert.equal((await (await proxy.control('/status')).json()).attempt, 1) +}) + test('ACK-loss proxy only targets one event and forwards committed upstream results before dropping ACK', async t => { const proxy = await fixture() t.after(() => proxy.stop()) @@ -260,3 +280,33 @@ test('delivery selection rejects malformed IDs or a second selector before openi assert.ok(!result.stdout.includes('"ready"')) } }) + +test('fee void selector leaves publication, other sources and versions unchanged while recovering exact VOIDED ACK', async t => { + const sourceFeeId='2100000000000000011' + const proxy=await fixture(['--fee-source-id',sourceFeeId,'--source-version','2'],'BusinessFeeSourceChanged') + t.after(()=>proxy.stop()) + await proxy.control('/mode',{mode:'FAULT'}) + const voided={...event(),eventType:'BusinessFeeSourceChanged',payload:{sourceFeeId,sourceVersion:2,action:'VOIDED',totalAmount:30}} + for(const payload of [{...voided.payload,action:'PUBLISHED'},{...voided.payload,sourceVersion:1},{...voided.payload,sourceFeeId:'2100000000000000012'}, + {...voided.payload,action:'VOID'}]) assert.equal((await proxy.send({...voided,eventId:otherId,payload})).status,200) + assert.equal((await proxy.send(voided)).status,503) + assert.equal((await proxy.send(voided)).status,503) + assert.equal((await proxy.send({...voided,eventId:samePlanOtherId})).status,200) + const before=proxy.calls.length + assert.equal((await proxy.send({...voided,payload:{...voided.payload,sourceVersion:3}})).status,409) + assert.equal(proxy.calls.length,before) + await proxy.control('/mode',{mode:'PASS'}) + assert.equal(JSON.parse((await proxy.send(voided)).raw).data.outcome,'DUPLICATE') + const state=await (await proxy.control('/status')).json() + assert.deepEqual(state.records.filter(r=>r.errorCode!=='TARGET_EVENT_HASH_CONFLICT').map(r=>r.outcome),['APPLIED','DUPLICATE','DUPLICATE']) +}) + +test('fee void selector requires a bounded version paired with only its own source selector',()=>{ + for(const args of [['--fee-source-id','11'],['--event-id',targetId,'--source-version','1'],['--fee-source-id','0','--source-version','1'], + ['--fee-source-id','11','--source-version','0'],['--fee-source-id','11','--source-version','2147483648'], + ['--fee-source-id','../11','--source-version','1'],['--fee-source-id','11','--source-version','1','--delivery-id','88']]) { + const result=spawnSync(process.env.PYTHON||'python',[script,'--upstream','http://127.0.0.1:1/internal/events','--event-type','BusinessFeeSourceChanged',...args], + {encoding:'utf8',timeout:5000,env:{...process.env,EVENT_FAULT_CONTROL_TOKEN:controlToken}}) + assert.equal(result.status,2);assert.ok(!result.stdout.includes('"ready"')) + } +}) -- 2.54.0 From 49687457644c1016de02a913bfb8b8d7fa80fd31 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 09:02:25 +0800 Subject: [PATCH 085/176] test: verify fee void ACK loss with original event recovery --- scripts/README.md | 6 + scripts/fee-void-recovery-test-env.mjs | 170 ++++++++++++++++++ scripts/fee-void-recovery-test-env.test.mjs | 72 ++++++++ scripts/test-event-fault-executor.mjs | 26 ++- scripts/test-event-fault-runtime.py | 150 ++++++++++++++-- scripts/test-fee-void-fault-proof.mjs | 91 ++++++++++ scripts/test-fee-void-fault-proof.test.mjs | 189 ++++++++++++++++++++ 7 files changed, 688 insertions(+), 16 deletions(-) create mode 100644 scripts/fee-void-recovery-test-env.mjs create mode 100644 scripts/fee-void-recovery-test-env.test.mjs create mode 100644 scripts/test-fee-void-fault-proof.mjs create mode 100644 scripts/test-fee-void-fault-proof.test.mjs diff --git a/scripts/README.md b/scripts/README.md index d2e1cd427..5c7a20302 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,11 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fee-void-recovery-test-env.mjs` 是费用作废确认响应丢失的 TEST 专项入口,默认 `--plan` 零网络。执行须显式传入 `--execute --environment TEST --run-id <新批次> --customer-id <测试客户ID> --control-customer-id <另一测试客户ID> --warehouse-id <海外仓ID>`,外部 TEST/Jenkins 配置及 `FBA_CORE_JENKINS_AGENT` 提供凭据与取证节点。创建一条新仓储服务应收,经提交审核和原发布确认后,对该来源版本的 VOIDED 事件注入 ACK 丢失;原事件 DEAD 后受保护重试,核对最终作废、同命令重放、另一客户费用与完整库存不变。不写业务SQL,不使用旧费用作废;故障窗口会临时重建同镜像 Lastleg,禁止并行发布或其他共享环境写入。报告在 `reports/fee-void-<新批次>/`,请求前保存意图,失败保留样本,不自动重跑或覆盖;finally 恢复原服务、停止代理并退出会话,恢复未证明则失败。`fee-void-recovery-test-env.test.mjs` 是离线编排与失败清理检查,不能代替真实环境结果。 + +`test-fee-void-fault-proof.mjs` 是上述流程的内部只读证据校验器,无独立 CLI;配合 `test-event-fault-executor.mjs` 与 `test-event-fault-runtime.py` 检查原费用发布确认、精确作废事件、Finance 消费及 Lastleg 确认状态。实际运行结论只以该批次证据为准。 + +费用作废代理使用 `event-delivery-fault-proxy.py --fee-source-id <来源ID> --source-version <版本>`,两参数必须成对且不能与其他选择器混用,事件类型固定 `BusinessFeeSourceChanged`,仅匹配正式 action `VOIDED`。指向正式费用消费路径时,同源 `/internal/finance/v1/wallet-command-events` 保留原请求与响应直通,不进入费用故障选择;其他未配置路径仍拒绝。此边界由代理离线测试覆盖。 + `test-delivery-fault-proof.mjs` 是签收回写故障的内部证据校验器,无CLI、网络或写入。按精确客户/派送/来源入仓/来源订单/根订单核对原事件及载荷摘要、13次投递与代理记录、唯一收件箱和人工重试审计,并比较重试前后的Firstleg与Order事实。首片签收仍有有效余量时允许根单继续等待;不把根单必须立即COMPLETED当成成功条件。 F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。 diff --git a/scripts/fee-void-recovery-test-env.mjs b/scripts/fee-void-recovery-test-env.mjs new file mode 100644 index 000000000..3271ae95b --- /dev/null +++ b/scripts/fee-void-recovery-test-env.mjs @@ -0,0 +1,170 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, existsSync } from 'node:fs' +import { dirname, resolve, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { redactEvidence } from './fba-core-regression-test-env.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const FEES = '/api/platform/v1/last-leg/finance-fee-sources' +const TYPE = 'OVERSEAS_WAREHOUSE_FEE' +const id = value => String(value ?? '') +const check = (value, message) => { if (!value) throw Error(message) } +export function parseFeeVoidArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan' }, seen = new Set() + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (['--plan', '--execute'].includes(arg)) { + check(!seen.has('action'), 'Choose one execution action'); seen.add('action'); config.execute = arg === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--customer-id': 'customerId', '--control-customer-id': 'controlCustomerId', '--warehouse-id': 'warehouseId' }[arg] + check(field && !seen.has(arg) && args[i + 1] && !args[i + 1].startsWith('--'), 'Invalid or duplicate argument') + seen.add(arg); config[field] = args[++i] + } + validateConfig(config) + if (config.execute) check(seen.has('--environment'), 'Execution requires explicit TEST') + return config +} +function validateConfig(config) { + check(config.environment === 'TEST' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId), 'TEST and safe run-id required') + if (config.execute) check(config.runId !== 'plan' && ['customerId', 'controlCustomerId', 'warehouseId'].every(key => typeof config[key] === 'string' && /^[1-9][0-9]{0,19}$/.test(config[key])) + && config.customerId !== config.controlCustomerId, 'Two explicit distinct customers and warehouse required') +} +export function feeVoidPlan(config) { + return { environment: 'TEST', execute: config.execute, runId: config.runId, customerId: config.customerId, controlCustomerId: config.controlCustomerId, + warehouseId: config.warehouseId, output: `reports/fee-void-${config.runId}`, sourceType: TYPE, amount: 17.31, currency: 'CNY', + stages: ['read customer/warehouse and isolation baselines', 'create/submit/approve new warehouse service fee', 'wait original publication', + 'inject only VOIDED ACK loss', 'original DEAD retry and full runtime restore', 'same-command replay and isolation readback'], + limits: 'Only a new unbilled TEST receivable. Retain fixtures and failures, never automatically restart. No business SQL writes. Exclusive Lastleg fault window; no overlapping deployments or writers.' } +} +export async function runFeeVoid(config, { env = process.env, outputRoot = ROOT, readers, faultFactory, + contextFactory = options => new AcceptanceRun(options), wait = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { + validateConfig(config) + if (!config.execute) return feeVoidPlan(config) + check(env.CHONGCHONG_ENV === 'test' && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'Explicit TEST credentials required') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + check(['http:', 'https:'].includes(base.protocol) && ['/api', '/api/'].includes(base.pathname) && !base.username && !base.password && !base.search && !base.hash, 'Invalid TEST gateway') + readers ||= createTestEvidenceReaders(env) + faultFactory ||= (await import('./test-event-fault-executor.mjs')).createTestFeeVoidFaultExecutor + check(typeof faultFactory === 'function' && typeof readers.readFleetInventory === 'function', 'Fee fault executor and complete inventory reader required') + const reports = resolve(outputRoot, 'reports'), output = join(reports, `fee-void-${config.runId}`) + mkdirSync(reports, { recursive: true }) + for (const name of ['.fba-core-regression.lock', '.fleet-acceptance.lock']) check(!existsSync(join(reports, name)), 'Another shared-environment acceptance is running or needs review') + const lockPath = join(reports, '.test-event-recovery.lock'), lock = openSync(lockPath, 'wx') + const report = { success: false, stage: 'PREFLIGHT', requests: [], inventory: [], goalComplete: false } + let context, fault, initialized = false, refreshToken, attempted = false, prepared = false + const secrets = [env.CHONGCHONG_ADMIN_PASSWORD, env.JENKINS_API_TOKEN] + const save = () => { if (initialized) writeFileSync(join(output, 'report.json'), JSON.stringify(redactEvidence(report, secrets), null, 2)) } + try { + writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) + mkdirSync(output); initialized = true + writeFileSync(join(output, 'manifest.json'), JSON.stringify(feeVoidPlan(config), null, 2)) + await readers.preflight() + context = contextFactory(validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), + environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })) + report.marker = context.marker + const request = async (method, path, body, headers = {}) => { + report.pending = { method, path, body }; save() + const result = await context.request(method, path, body, context.token, headers) + report.requests.push({ method, path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) + delete report.pending; save() + check(result.http === 200 && id(result.body?.code) === '200', `Fee recovery request failed: ${method} ${path} (${result.http}/${result.body?.code})`) + return result.body.data + } + const login = await request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }) + check(login?.accessToken && login.refreshToken, 'Complete administrator session required') + context.token = login.accessToken; refreshToken = login.refreshToken; secrets.push(context.token, refreshToken) + const customers = [] + for (const customerId of [config.customerId, config.controlCustomerId]) { + const customer = await request('GET', `/api/platform/v1/iam/tenants/${customerId}`) + check(id(customer.id) === customerId && customer.status === 'ACTIVE' && customer.tenantName, 'Exact active customer required') + customers.push(customer) + } + const warehouse = await request('GET', `/api/platform/v1/warehouse-admin/${config.warehouseId}?tenantId=0`) + check(id(warehouse.warehouse?.id) === config.warehouseId && id(warehouse.warehouse.tenantId) === '0' && warehouse.warehouse.status === 'ACTIVE' + && warehouse.warehouse.warehouseType === 'OVERSEAS', 'Exact active overseas warehouse required') + const feeList = customerId => request('GET', `${FEES}?${new URLSearchParams({ customerId })}`) + const pool = customerId => request('GET', `/api/platform/finance/v1/fee-pool?${new URLSearchParams({ customerId, direction: 'RECEIVABLE' })}`) + const snapshot = async () => { + const value = [] + for (const customerId of [config.customerId, config.controlCustomerId]) { + const sources = await feeList(customerId), finance = await pool(customerId) + check(Array.isArray(sources) && sources.every(row => id(row.customerId) === customerId) && Array.isArray(finance) && finance.every(row => id(row.customerId) === customerId), 'Customer fee scope mismatch') + const inventory = await readers.readFleetInventory({ marker: context.marker, customerId, warehouseId: config.warehouseId }) + value.push({ customerId, sources, finance, inventory }) + } + return value + } + report.before = await snapshot() + report.stage = 'CREATE_NEW_FEE' + const body = { customerId: config.customerId, customerName: customers[0].tenantName, sourceType: TYPE, businessType: 'WAREHOUSE_SERVICE', + businessId: null, businessNo: `${context.marker}-VOID-TEST`, warehouseId: config.warehouseId, + feeItemCode: 'TEST-VOID-ACK', feeItemName: '受控测试作废响应丢失费用', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), + currency: 'CNY', amount: 17.31, paymentVoucherFileIds: [] } + report.createBody = body + let fee = await request('POST', FEES, body, { 'Idempotency-Key': `${context.marker}-CREATE` }) + report.sourceFeeId = id(fee.sourceFeeId) + check(/^[1-9][0-9]+$/.test(report.sourceFeeId) && fee.reviewStatus === 'DRAFT' && id(fee.customerId) === config.customerId && Number(fee.sourceVersion) === 1, 'New isolated fee draft required') + const path = `${FEES}/${report.sourceFeeId}` + fee = await request('POST', `${path}/submit`, { idempotencyKey: `${context.marker}-SUBMIT` }) + check(fee.reviewStatus === 'PENDING', 'New fee must await review') + fee = await request('POST', `${path}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: `${context.marker}-REVIEW` }) + check(fee.reviewStatus === 'APPROVED' && fee.publicationStatus === 'PUBLISHED' && fee.sourceStatus === 'ACTIVE', 'Approved active source required') + report.approved = fee + let published = [] + for (let i = 0; i < 30; i++) { + published = (await pool(config.customerId)).filter(row => id(row.sourceId) === report.sourceFeeId && Number(row.sourceVersion) === 1) + if (published.length) break + await wait(1000) + } + check(published.length === 1 && Number(published[0].amount) === 17.31 && published[0].currency === 'CNY', 'Original approved fee must exist exactly once in Finance') + report.originalFinance = published + report.scope = { marker: context.marker, customerId: config.customerId, sourceFeeId: report.sourceFeeId, sourceVersion: '1', sourceType: TYPE } + fault = faultFactory(env) + report.stage = 'PREPARE_INTENT'; attempted = true; save() + await fault.prepare(report.scope) + prepared = true + report.stage = 'VOID_REQUEST' + report.voidRequest = { expectedVersion: 1, reason: '受控TEST作废确认响应丢失验收', idempotencyKey: `${context.marker}-VOID` } + const pending = await request('POST', `${path}/void`, report.voidRequest) + check(pending.sourceStatus === 'ACTIVE' && pending.voidStatus === 'PENDING_FINANCE', 'Sending void request must not confirm business-side cancellation') + report.pendingSource = pending; report.stage = 'WAIT_DEAD_AND_RECOVER'; save() + report.eventProof = await fault.readProof(report.scope) + report.stage = 'RESTORED_READBACK'; save() + const confirmed = (await feeList(config.customerId)).filter(row => id(row.sourceFeeId) === report.sourceFeeId) + check(confirmed.length === 1 && confirmed[0].sourceStatus === 'VOIDED' && confirmed[0].voidStatus === 'CONFIRMED' && Number(confirmed[0].sourceVersion) === 1, 'Matching Finance ACK must confirm only the original source version') + report.confirmedSource = confirmed[0] + const replay = await request('POST', `${path}/void`, report.voidRequest) + check(isDeepStrictEqual(replay, confirmed[0]), 'Same void command replay changed the confirmed source') + report.after = await snapshot() + check(isDeepStrictEqual(report.before[1], report.after[1]), 'Other customer fees or inventory changed') + check(isDeepStrictEqual(report.before[0].inventory, report.after[0].inventory), 'Fee cancellation changed inventory') + check(isDeepStrictEqual(report.before[0].sources, report.after[0].sources.filter(row => id(row.sourceFeeId) !== report.sourceFeeId)), 'Unrelated original source changed') + check(isDeepStrictEqual(report.before[0].finance, report.after[0].finance), 'Voided fee remains in pool or unrelated finance row changed') + report.success = true; report.stage = 'VERIFIED' + } catch (error) { report.error = error.message; report.success = false; report.stage = 'FAILED' } + finally { + if (fault) try { + report.restoration = await fault.cleanup() + const noMutation = !prepared && report.restoration?.attempted === false + if (attempted && !noMutation && !(report.restoration?.restored && report.restoration?.healthy && report.restoration?.proxyStopped)) throw Error('Fee void restoration not verified') + } catch (error) { report.success = false; report.stage = 'RESTORATION_FAILED'; report.restorationError = error.message } + if (context?.token) try { + const result = await context.request('POST', '/api/auth/logout', { refreshToken }, context.token) + report.sessionClosed = result.http === 200 && id(result.body?.code) === '200' + if (!report.sessionClosed) report.success = false + } catch (error) { report.success = false; report.sessionClosed = false; report.logoutError = error.message } + try { save() } finally { closeSync(lock); unlinkSync(lockPath) } + } + return report +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseFeeVoidArguments(process.argv.slice(2)), result = await runFeeVoid(config) + console.log(JSON.stringify(config.execute ? { success: result.success, stage: result.stage, error: result.error, sourceFeeId: result.sourceFeeId, restored: result.restoration?.restored, output: feeVoidPlan(config).output } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/scripts/fee-void-recovery-test-env.test.mjs b/scripts/fee-void-recovery-test-env.test.mjs new file mode 100644 index 000000000..4ebe8b729 --- /dev/null +++ b/scripts/fee-void-recovery-test-env.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, basename, resolve } from 'node:path' +import { parseFeeVoidArguments, runFeeVoid } from './fee-void-recovery-test-env.mjs' + +const config = () => parseFeeVoidArguments(['--execute', '--environment', 'TEST', '--run-id', 'offline', + '--customer-id', '41', '--control-customer-id', '42', '--warehouse-id', '900']) +const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_USERNAME: 'offline-admin', + CHONGCHONG_ADMIN_PASSWORD: 'private-password', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.example.invalid/api' } +function harness(outputRoot, failure) { + const calls = []; let fee, finished = false, cleanup = 0 + const context = { marker: 'FEE-OFFLINE', async request(method, path, body) { + calls.push({ method, path }) + let data + if (path === '/api/auth/login') data = { accessToken: 'private-access', refreshToken: 'private-refresh' } + else if (path === '/api/auth/logout') { if (failure === 'logout') throw Error('LOGOUT_TRANSPORT'); data = {} } + else if (path.includes('/iam/tenants/')) data = { id: path.split('/').at(-1), tenantName: 'Offline customer', status: 'ACTIVE' } + else if (path.includes('/warehouse-admin/')) data = { warehouse: { id: '900', tenantId: '0', status: 'ACTIVE', warehouseType: 'OVERSEAS' } } + else if (path.includes('/fee-pool?')) data = path.includes('customerId=41') && fee?.reviewStatus === 'APPROVED' && (!finished || failure === 'pool') + ? [{ customerId: '41', sourceId: '81', sourceVersion: 1, amount: 17.31, currency: 'CNY' }] : [] + else if (method === 'GET') data = path.includes('customerId=41') && fee ? [structuredClone(fee)] + : finished && failure === 'isolation' ? [{ customerId: '42', sourceFeeId: '99' }] : [] + else if (path.endsWith('/submit')) { fee.reviewStatus = 'PENDING'; data = fee } + else if (path.endsWith('/review')) { Object.assign(fee, { reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED', sourceStatus: 'ACTIVE' }); data = fee } + else if (path.endsWith('/void')) { + fee.voidStatus = finished ? 'CONFIRMED' : 'PENDING_FINANCE' + if (failure === 'early-confirm') fee.sourceStatus = 'VOIDED' + data = fee + } else { fee = { sourceFeeId: '81', sourceVersion: 1, customerId: body.customerId, reviewStatus: 'DRAFT' }; data = fee } + return { http: 200, body: { code: '200', data: structuredClone(data) } } + } } + return { calls, get cleanup() { return cleanup }, options: { env, outputRoot, contextFactory: () => context, + wait: async () => {}, readers: { preflight: async () => {}, readFleetInventory: async scope => ({ scope, balances: [], positions: [], ledger: [] }) }, + faultFactory: () => ({ prepare: async () => { if (['prepare', 'preflight'].includes(failure)) throw Error('PREPARE_FAILED') }, + readProof: async () => { finished = true; Object.assign(fee, { sourceStatus: 'VOIDED', voidStatus: 'CONFIRMED' }); return { status: 'VERIFIED' } }, + cleanup: async () => { cleanup++; return failure === 'preflight' || failure === 'missing-restoration' + ? { attempted: false } : { restored: failure !== 'restore', healthy: true, proxyStopped: true } } }) } } +} +test('fee void plan has no network and refuses implicit environment or ambiguous customer scope', async () => { + assert.equal((await runFeeVoid(parseFeeVoidArguments([]), { env: {} })).execute, false) + for (const args of [['--execute'], ['--environment', 'PROD'], ['--run-id', '../escape'], ['--plan', '--execute']]) + assert.throws(() => parseFeeVoidArguments(args)) + await assert.rejects(runFeeVoid({ ...config(), controlCustomerId: '41' }, { env }), /distinct/) + await assert.rejects(runFeeVoid(config(), { env: { ...env, CHONGCHONG_ENV: 'prod' } }), /TEST/) +}) +for (const failure of [undefined, 'prepare', 'preflight', 'early-confirm', 'isolation', 'pool', 'restore', 'missing-restoration', 'logout']) { + test(`fee void ${failure || 'success'} retains evidence and releases its lock after cleanup`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fee-void-contract-')) + try { + const h = harness(root, failure), report = await runFeeVoid(config(), h.options) + assert.equal(report.success, !failure, JSON.stringify(report)) + assert.equal(h.cleanup, 1) + if (failure === 'preflight') assert.equal(report.stage, 'FAILED') + if (failure === 'missing-restoration') assert.equal(report.stage, 'RESTORATION_FAILED') + assert.equal(h.calls.filter(c => c.path === '/api/auth/logout').length, 1) + assert.equal(existsSync(join(root, 'reports/.test-event-recovery.lock')), false) + const path = join(root, 'reports/fee-void-offline/report.json'), saved = readFileSync(path, 'utf8') + assert.doesNotMatch(saved, /private-password|private-access|private-refresh/) + if (failure === 'logout') assert.equal(JSON.parse(saved).logoutError, 'LOGOUT_TRANSPORT') + const previous = h.calls.length + assert.equal((await runFeeVoid(config(), h.options)).success, false) + assert.equal(h.calls.length, previous) + assert.equal(readFileSync(path, 'utf8'), saved) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.ok(basename(root).startsWith('fee-void-contract-')) + rmSync(root, { recursive: true, force: true }) + } + }) +} diff --git a/scripts/test-event-fault-executor.mjs b/scripts/test-event-fault-executor.mjs index 2f587be62..c3fbab69e 100644 --- a/scripts/test-event-fault-executor.mjs +++ b/scripts/test-event-fault-executor.mjs @@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' import { assertTestDeliveryFaultProof, deliveryFaultScopeKeys } from './test-delivery-fault-proof.mjs' +import { assertTestFeeVoidFaultProof, feeVoidScopeKeys, feeSourceTypes } from './test-fee-void-fault-proof.mjs' +export { assertTestFeeVoidFaultProof } from './test-fee-void-fault-proof.mjs' export { assertTestDeliveryFaultProof } from './test-delivery-fault-proof.mjs' import { validateEvidenceConfig } from './fba-core-regression-evidence.mjs' @@ -19,10 +21,12 @@ export function renderTestUnloadFaultAction(action, scope, agent) { return renderAction(action,scope,agent,'UNLOAD') } function renderAction(action,scope,agent,direction) { - const keys=direction==='DELIVERY'?deliveryFaultScopeKeys:direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId'] - if (!['prepare', 'activate', 'proxy-ready', 'health', 'status', 'recover', 'proof', 'restore', 'finish'].includes(action) + const keys=direction==='FEE_VOID'?feeVoidScopeKeys:direction==='DELIVERY'?deliveryFaultScopeKeys:direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId'] + if (!(action==='preflight'&&direction==='FEE_VOID')&&!['prepare', 'activate', 'proxy-ready', 'health', 'status', 'recover', 'proof', 'restore', 'finish'].includes(action) || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent) || !/^[A-Za-z0-9_-]{1,96}$/.test(scope?.marker || '') - || keys.filter(key=>key!=='marker').some(key => !/^[1-9][0-9]{0,19}$/.test(scope?.[key] || '')) + || keys.filter(key=>!['marker','sourceType'].includes(key)).some(key => !/^[1-9][0-9]{0,19}$/.test(scope?.[key] || '')) + || (direction==='FEE_VOID'&&(!feeSourceTypes.includes(scope?.sourceType)||Number(scope?.sourceVersion)>2147483647)) + || (direction==='FEE_VOID'&&keys.some(key=>typeof scope?.[key]!=='string')) || (direction!=='LOADING'&&Object.keys(scope).some(key=>!keys.includes(key)))) { throw Error('Invalid TEST fault action or scope') } @@ -37,6 +41,9 @@ export function createTestFaultExecutor(env, fetcher = globalThis.fetch) { export function createTestDeliveryFaultExecutor(env, fetcher = globalThis.fetch) { return createExecutor(env,fetcher,'DELIVERY') } +export function createTestFeeVoidFaultExecutor(env, fetcher = globalThis.fetch) { + return createExecutor(env,fetcher,'FEE_VOID') +} export function createTestUnloadFaultExecutor(env, fetcher = globalThis.fetch) { return createExecutor(env,fetcher,'UNLOAD') } @@ -93,7 +100,7 @@ export function assertTestUnloadFaultProof(proof,scope) { function createExecutor(env,fetcher,direction) { validateEvidenceConfig(env) if (env.CHONGCHONG_ENV !== 'test') throw Error('TEST fault requires CHONGCHONG_ENV=test') - let scope = null, finished = false, preparedEvidence=null, restoration=null + let scope = null, finished = false, preparedEvidence=null, restoration=null, preparationAttempted=false, ready=false async function action(name) { const script = renderAction(name, scope, env.FBA_CORE_JENKINS_AGENT,direction) const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { @@ -118,6 +125,7 @@ function createExecutor(env,fetcher,direction) { throw Error('TEST fault observation timed out: ' + name) } async function cleanup() { + if(direction==='FEE_VOID'&&!preparationAttempted)return {attempted:false} if (!scope || finished) return restoration await action('restore') await wait('health', row => row.healthy) @@ -127,7 +135,7 @@ function createExecutor(env,fetcher,direction) { &&restoration?.restored===true&&restoration.healthy===true&&restoration.proxyStopped===true &&restoration.producerName==='firstleg-test-lastleg'&&restoration.originalImageId===preparedEvidence?.originalImageId &&restoration.currentImageId===preparedEvidence?.originalImageId&&restoration.originalFingerprint===preparedEvidence?.originalFingerprint - &&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error((direction==='DELIVERY'?'Delivery':'Unload')+' restoration evidence mismatch') + &&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error((direction==='FEE_VOID'?'Fee void':direction==='DELIVERY'?'Delivery':'Unload')+' restoration evidence mismatch') finished = true return restoration } @@ -135,15 +143,19 @@ function createExecutor(env,fetcher,direction) { async prepare(next) { if (scope) { if(direction!=='LOADING'&&!isDeepStrictEqual(scope,next))throw Error('TEST unload fault scope drift') + if(direction==='FEE_VOID'&&!ready)throw Error('Fee void preparation did not complete') return // Pin only the first actual current-run plan; other events pass through. } if(direction!=='LOADING')renderAction('prepare',next,env.FBA_CORE_JENKINS_AGENT,direction) scope = direction!=='LOADING'?{...next}:{ marker: next.marker, customerId: next.customerId, loadingPlanId: next.loadingPlanId, containerBookingId: next.containerBookingId } + if(direction==='FEE_VOID')await wait('preflight', row=>row.ready===true,60) + preparationAttempted=true preparedEvidence=await action('prepare') await wait('proxy-ready', row => row.ready, 10, true) await action('activate') await wait('health', row => row.healthy) + ready=true }, async readProof(expected) { if (!scope || (direction!=='LOADING'?!isDeepStrictEqual(scope,expected):Object.keys(scope).some(key => expected?.[key] !== scope[key]))) throw Error('TEST fault scope drift') @@ -154,6 +166,10 @@ function createExecutor(env,fetcher,direction) { const proof = await action('proof') if(direction==='UNLOAD')assertTestUnloadFaultProof(proof,scope) if(direction==='DELIVERY')assertTestDeliveryFaultProof(proof,scope) + if(direction==='FEE_VOID') { + if(!isDeepStrictEqual(proof.prerequisite,preparedEvidence?.prerequisite))throw Error('Fee prerequisite evidence drift') + assertTestFeeVoidFaultProof(proof,scope) + } await cleanup() return { ...proof, restored: true, ...(direction!=='LOADING'?{restoration}:{}) } } finally { if(direction!=='LOADING')await cleanup() } diff --git a/scripts/test-event-fault-runtime.py b/scripts/test-event-fault-runtime.py index 7a4662f5d..de8b5c469 100644 --- a/scripts/test-event-fault-runtime.py +++ b/scripts/test-event-fault-runtime.py @@ -6,6 +6,7 @@ Only the original DEAD event can be retried through its protected HTTP API. """ import base64 import copy +import decimal import hashlib import http.client import json @@ -103,21 +104,34 @@ def private_write(path, value): def scope_directory(scope, direction='LOADING'): - if direction not in ('LOADING', 'UNLOAD', 'DELIVERY'): + if direction not in ('LOADING', 'UNLOAD', 'DELIVERY', 'FEE_VOID'): raise RuntimeError('INVALID_DIRECTION') marker = scope.get('marker', '') if not re.fullmatch(r'[A-Za-z0-9_-]{1,96}', marker): raise RuntimeError('INVALID_SCOPE') - keys = ('customerId', 'deliveryId', 'sourceInboundId', 'sourceOrderId', 'rootOrderId') if direction == 'DELIVERY' else ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId') + keys = ('customerId', 'sourceFeeId', 'sourceVersion', 'sourceType') if direction == 'FEE_VOID' else ('customerId', 'deliveryId', 'sourceInboundId', 'sourceOrderId', 'rootOrderId') if direction == 'DELIVERY' else ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId') if direction != 'LOADING' and set(scope) != {'marker', *keys}: raise RuntimeError('INVALID_SCOPE') + if direction == 'FEE_VOID' and scope.get('sourceType') not in FEE_SOURCE_TYPES: + raise RuntimeError('INVALID_FEE_SOURCE_TYPE') + if direction == 'FEE_VOID' and (not str(scope.get('sourceVersion', '')).isdigit() or not 0 < int(scope['sourceVersion']) <= 2147483647): + raise RuntimeError('INVALID_SOURCE_VERSION') for key in keys: + if key == 'sourceType': + continue if not re.fullmatch(r'[1-9][0-9]{0,19}', scope.get(key, '')): raise RuntimeError('INVALID_SCOPE') identity = direction + ':' + marker if direction != 'LOADING' else marker return Path(tempfile.gettempdir()) / ('chongchong-event-' + hashlib.sha256(identity.encode()).hexdigest()[:24]) +FEE_SOURCE_TYPES = ('OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE', 'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE', 'OVERSEAS_CONTAINER_FEE', 'OVERSEAS_ORDER_FEE', 'OVERSEAS_DELIVERY_FEE', 'OVERSEAS_RELABEL_FEE') + + +def fee_void(state): + return state.get('direction') == 'FEE_VOID' + + def unload(state): return state.get('direction', 'LOADING') == 'UNLOAD' @@ -127,7 +141,7 @@ def delivery(state): def reverse_delivery(state): - return state.get('direction') in ('UNLOAD', 'DELIVERY') + return state.get('direction') in ('UNLOAD', 'DELIVERY', 'FEE_VOID') def producer_name(state): @@ -135,6 +149,8 @@ def producer_name(state): def target_setting(state): + if fee_void(state): + return ('FINANCE_SERVICE_URL', 'http://finance:9203') return ('FIRSTLEG_SERVICE_URL', 'http://firstleg:9202') if reverse_delivery(state) else ('LASTLEG_SERVICE_URL', 'http://lastleg-service:9303') @@ -167,7 +183,7 @@ def read_sql(container, sql): if re.search(r'\b(INSERT|UPDATE|DELETE|ALTER|DROP|TRUNCATE|CALL|INTO|OUTFILE)\b', sql, re.I): raise RuntimeError('NON_READ_ONLY_SQL') e = env_of(container) - prefix = {'/firstleg-test-business': 'FIRSTLEG', '/firstleg-test-lastleg': 'LASTLEG', '/firstleg-test-order': 'ORDER'}[container['Name']] + prefix = {'/firstleg-test-business': 'FIRSTLEG', '/firstleg-test-lastleg': 'LASTLEG', '/firstleg-test-order': 'ORDER', '/firstleg-test-finance': 'FINANCE'}[container['Name']] url = urllib.parse.urlsplit(e[prefix + '_DB_URL'].removeprefix('jdbc:')) host = '127.0.0.1' if url.hostname == 'host.docker.internal' else url.hostname transaction = 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\n' + sql + '\nCOMMIT;' @@ -182,6 +198,8 @@ def read_sql(container, sql): def producer(scope, direction='LOADING'): + if direction == 'FEE_VOID': + return fee_event(scope, 'VOIDED') if direction == 'DELIVERY': rows = read_sql(inspect('firstleg-test-lastleg'), "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='" + scope['customerId'] + "' AND event_type='OverseasDeliveryCompleted' AND JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.deliveryOrderId'))='" + scope['deliveryId'] + "';") @@ -212,7 +230,101 @@ def business_ids(scope): return [row['id'] for row in rows] +def fee_canonical(value): + """Match EventPayloadCanonicalizer's sorted JSON and stripped BigDecimal.toString.""" + if isinstance(value, dict): + return '{' + ','.join(json.dumps(key, ensure_ascii=False) + ':' + fee_canonical(value[key]) for key in sorted(value)) + '}' + if isinstance(value, list): + return '[' + ','.join(fee_canonical(item) for item in value) + ']' + if isinstance(value, decimal.Decimal): + with decimal.localcontext() as context: + context.prec = max(28, len(value.as_tuple().digits)) + number = value.normalize() + if not number: + return '0' + return format(number, 'f') if number.as_tuple().exponent <= 0 and number.adjusted() >= -6 else str(number) + return json.dumps(value, ensure_ascii=False, separators=(',', ':')) + + +def fee_event(scope, action): + if action not in ('PUBLISHED', 'ADJUSTED', 'VOIDED'): + raise RuntimeError('INVALID_FEE_ACTION') + event_key = 'BusinessFeeSourceChanged:LASTLEG:' + scope['sourceType'] + ':' + scope['sourceFeeId'] + ':' + scope['sourceVersion'] + ':' + action + rows = read_sql(inspect('firstleg-test-lastleg'), + "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'envelopeJson',payload_json,'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE event_type='BusinessFeeSourceChanged' AND event_key='" + event_key + "';") + if len(rows) > 1: + raise RuntimeError('AMBIGUOUS_FEE_EVENT') + if not rows: + return None + row = rows[0] + payload = json.loads(row['envelopeJson'], parse_float=decimal.Decimal, parse_int=decimal.Decimal)['payload'] + row['payloadCanonicalJson'] = fee_canonical(payload) + unsigned = {**payload, 'action': None, 'sourceStatus': 'ACTIVE', 'sourceHash': None} + row['sourceCanonicalJson'] = fee_canonical(unsigned) + if hashlib.sha256(row['payloadCanonicalJson'].encode()).hexdigest() != row['payloadHash'].removeprefix('sha256:') or payload.get('sourceHash') != 'sha256:' + hashlib.sha256(row['sourceCanonicalJson'].encode()).hexdigest(): + raise RuntimeError('FEE_PAYLOAD_HASH_MISMATCH') + if row['customerId'] != scope['customerId'] or row['aggregateId'] != scope['sourceFeeId'] or row['aggregateType'] != scope['sourceType']: + raise RuntimeError('FEE_EVENT_SCOPE_MISMATCH') + if payload.get('sourceSystem') != 'LASTLEG' or payload.get('customerId') != scope['customerId'] or payload.get('sourceFeeId') != scope['sourceFeeId'] or str(payload.get('sourceVersion')) != scope['sourceVersion'] or payload.get('sourceType') != scope['sourceType'] or payload.get('action') != action: + raise RuntimeError('FEE_PAYLOAD_SCOPE_MISMATCH') + return row + + +def fee_inbox(scope, event_id): + if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id): + raise RuntimeError('INVALID_EVENT_IDENTITY') + return read_sql(inspect('firstleg-test-finance'), + "SELECT JSON_OBJECT('id',CAST(id AS CHAR),'eventId',event_id,'sourceSystem',source_system,'eventType',event_type,'envelope',JSON_EXTRACT(payload,'$'),'payloadHash',payload_hash,'resultResourceId',CAST(result_resource_id AS CHAR),'status',status,'errorCode',error_code,'receivedAt',CAST(received_at AS CHAR),'processedAt',CAST(processed_at AS CHAR)) FROM fin_inbox_event WHERE source_system='LASTLEG' AND event_id='" + event_id + "';") + + +def fee_facts(scope, event_id): + lastleg, finance = inspect('firstleg-test-lastleg'), inspect('firstleg-test-finance') + require_test(lastleg, 'firstleg-test-lastleg') + require_test(finance, 'firstleg-test-finance') + # Scalar decimals remain strings. Original event decimals are retained in envelopeJson. + def columns(names): + return ','.join("'" + name + "',CAST(" + re.sub(r'([A-Z])', lambda match: '_' + match[1].lower(), name) + " AS CHAR)" for name in names.split()) + local_names = 'id sourceFeeId sourceVersion customerId customerName sourceType sourceNo businessType businessId businessNo orderId inboundId inboundCode containerId containerBookingId containerNo warehouseId feeItemCode feeItemName direction supplierId supplierName supplierCategory settlementMode paidAt paymentMethod paymentReferenceNo businessDate currency amount originalCurrency originalAmount exchangeRate exchangeRateMonth chargeableQuantity quantityUnit unitPrice periodStart periodEnd reviewStatus publicationStatus sourceStatus reviewReason reviewerId reviewedAt createdBy updatedBy createdAt updatedAt' + local = read_sql(lastleg, "SELECT JSON_OBJECT(" + columns(local_names) + ",'paymentVoucherFileIds',payment_voucher_file_ids) FROM ll_finance_fee_source WHERE source_fee_id=" + scope['sourceFeeId'] + " ORDER BY source_version,id;") + where = "source_system='LASTLEG' AND source_type='" + scope['sourceType'] + "' AND source_fee_id=" + scope['sourceFeeId'] + finance_names = 'id sourceSystem sourceFeeId sourceFeeVersion customerId sourceType sourceNo businessType businessId businessNo inboundCode warehouseId direction orderId inboundId containerId containerBookingId containerNo feeItemCode feeItemName amount currencyCode settlementMode paidAt paymentMethod paymentReferenceNo sourceHash reviewStatus reviewReason operationVersion walletHoldId status createdAt updatedAt' + ids = 'SELECT id FROM fin_fee_source WHERE ' + where + rows = read_sql(finance, + "SELECT JSON_OBJECT('kind','source'," + columns(finance_names) + ",'sourceSnapshot',source_snapshot,'paymentVoucherFileIds',payment_voucher_file_ids) FROM fin_fee_source WHERE " + where + " ORDER BY source_fee_version,id;\n" + + "SELECT JSON_OBJECT('kind','bill'," + columns('id tenantId billId sourceId sourceVersion amount currencyCode createdAt') + ") FROM fin_bill_line WHERE source_id IN (" + ids + ") ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','settlement'," + columns('id tenantId settlementId feeSourceId amount currencyCode createdAt') + ") FROM fin_settlement_line WHERE fee_source_id IN (" + ids + ") ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','cost'," + columns('id tenantId sourceSystem sourceType sourceFeeId sourceFeeVersion sourceHash status version costAmount createdAt voidedAt') + ") FROM fin_cost WHERE " + where + " ORDER BY id;") + return {'lastleg': local, 'finance': {**{kind: [row for row in rows if row['kind'] == kind] for kind in ('source', 'bill', 'settlement', 'cost')}, 'inbox': fee_inbox(scope, event_id)}} + + +def fee_prerequisite(scope, wait=False): + if fee_event(scope, 'VOIDED') is not None: + raise RuntimeError('FEE_VOID_EVENT_ALREADY_EXISTS') + publication = fee_event(scope, 'PUBLISHED' if scope['sourceVersion'] == '1' else 'ADJUSTED') + if not publication or publication['publishStatus'] not in ('PENDING', 'PROCESSING', 'PUBLISHED'): + raise RuntimeError('FEE_PUBLICATION_NOT_READY') + facts = fee_facts(scope, publication['eventId']) + current = [row for row in facts['lastleg'] if row['sourceVersion'] == scope['sourceVersion']] + financial = [row for row in facts['finance']['source'] if row['sourceFeeVersion'] == scope['sourceVersion']] + if len(current) != 1 or len(financial) != 1 or any(row['customerId'] != scope['customerId'] or row['sourceType'] != scope['sourceType'] for row in facts['lastleg'] + facts['finance']['source']): + raise RuntimeError('FEE_SOURCE_SCOPE_MISMATCH') + if max(int(row['sourceVersion']) for row in facts['lastleg']) != int(scope['sourceVersion']) or current[0]['sourceStatus'] != 'ACTIVE' or current[0]['reviewStatus'] != 'APPROVED' or current[0]['publicationStatus'] != 'PUBLISHED' or current[0]['direction'] != 'RECEIVABLE': + raise RuntimeError('FEE_SOURCE_NOT_ACTIVE_RECEIVABLE') + if financial[0]['status'] != 'AVAILABLE' or financial[0]['walletHoldId'] is not None or any(facts['finance'][key] for key in ('bill', 'settlement', 'cost')): + raise RuntimeError('FEE_SOURCE_ALREADY_FINANCIALLY_LINKED') + inbox = facts['finance']['inbox'] + if len(inbox) != 1 or inbox[0]['status'] != 'PROCESSED' or inbox[0]['payloadHash'] != publication['payloadHash'] or inbox[0]['resultResourceId'] != financial[0]['id']: + raise RuntimeError('FEE_PUBLICATION_INBOX_MISMATCH') + attempts = read_sql(inspect('firstleg-test-lastleg'), "SELECT JSON_OBJECT('entryId',entry_id,'eventId',event_id,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='" + publication['entryId'] + "' AND event_type='BusinessFeeSourceChanged' ORDER BY started_at,attempt_id;") + ready = publication['publishStatus'] == 'PUBLISHED' and any(row['eventId'] == publication['eventId'] and row['outcome'] in ('APPLIED', 'DUPLICATE') for row in attempts) + if not ready and not wait: + raise RuntimeError('FEE_PUBLICATION_ACK_NOT_READY') + return {'ready': ready, 'publication': publication, 'business': facts, 'attempts': attempts} + + def business_snapshot(scope, state, row): + if fee_void(state): + return {'fee': fee_facts(scope, row['eventId'])} if delivery(state): return {'business': delivery_facts(scope, row['eventId'])} return {'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)} @@ -245,10 +357,11 @@ def delivery_facts(scope, event_id): def prepare(scope, directory, config): if delivery(config) and producer(scope, 'DELIVERY') is not None: raise RuntimeError('DELIVERY_EVENT_ALREADY_EXISTS') + prerequisite = fee_prerequisite(scope) if fee_void(config) else None directory.mkdir(mode=0o700) original = inspect(producer_name(config)) require_test(original, producer_name(config)) - consumer_name = 'firstleg-test-business' if reverse_delivery(config) else 'firstleg-test-lastleg' + consumer_name = 'firstleg-test-finance' if fee_void(config) else 'firstleg-test-business' if reverse_delivery(config) else 'firstleg-test-lastleg' consumer = inspect(consumer_name) require_test(consumer, consumer_name) old_env = env_of(original) @@ -268,10 +381,12 @@ def prepare(scope, directory, config): proxy.write_bytes(base64.b64decode(config['proxySource'])) ip = consumer['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] log = open(directory / 'proxy-output.log', 'wb') - upstream = ':9202/internal/firstleg/v1/container-events' if reverse_delivery(config) else ':9303/internal/lastleg/events' - selector = [] if delivery(config) else ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']] + upstream = ':9203/internal/finance/v1/business-fee-source-events' if fee_void(config) else ':9202/internal/firstleg/v1/container-events' if reverse_delivery(config) else ':9303/internal/lastleg/events' + selector = [] if (delivery(config) or fee_void(config)) else ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']] if delivery(config): selector = ['--event-type', 'OverseasDeliveryCompleted', '--delivery-id', scope['deliveryId']] + if fee_void(config): + selector = ['--event-type', 'BusinessFeeSourceChanged', '--fee-source-id', scope['sourceFeeId'], '--source-version', scope['sourceVersion']] process = subprocess.Popen(['python3', str(proxy), '--upstream', 'http://' + ip + upstream, *selector, '--bind', '0.0.0.0', '--port', str(ports[0]), '--control-port', str(ports[1]), '--log', str(directory / 'proxy-evidence.jsonl')], @@ -279,9 +394,9 @@ def prepare(scope, directory, config): state = {'scope': scope, 'originalBody': original_body, 'originalId': original['Id'], 'image': original['Config']['Image'], 'direction': config.get('direction', 'LOADING'), 'originalImageId': original['Image'], 'originalFingerprint': fingerprint(original), 'proxyPid': process.pid, 'proxyPort': ports[0], 'controlPort': ports[1], 'controlToken': token, - 'operationsRequestId': str(uuid.uuid4()), 'prepared': False, 'restored': False} + 'prerequisite': prerequisite, 'operationsRequestId': str(uuid.uuid4()), 'prepared': False, 'restored': False} private_write(directory / 'state.json', state) - return {'created': True, 'originalImageId': state['originalImageId'], 'originalFingerprint': state['originalFingerprint']} + return {'created': True, 'originalImageId': state['originalImageId'], 'originalFingerprint': state['originalFingerprint'], **({'prerequisite': prerequisite} if fee_void(config) else {})} def activate(scope, directory, state): @@ -316,6 +431,12 @@ def recover(scope, directory, state): if pinned['eventId'] != row['eventId'] or pinned['payloadHash'] != row['payloadHash']: raise RuntimeError('PINNED_EVENT_MISMATCH') state['beforeRetry'] = {**row, **business_snapshot(scope, state, row)} + if fee_void(state): + facts = state['beforeRetry']['fee'] + local = [value for value in facts['lastleg'] if value['sourceVersion'] == scope['sourceVersion']] + financial = [value for value in facts['finance']['source'] if value['sourceFeeVersion'] == scope['sourceVersion']] + if len(local) != 1 or local[0]['sourceStatus'] != 'ACTIVE' or len(financial) != 1 or financial[0]['status'] != 'VOIDED': + raise RuntimeError('FEE_VOID_DEAD_PHASE_MISMATCH') private_write(directory / 'state.json', state) control(state, 'PASS') container = inspect(producer_name(state)) @@ -375,7 +496,7 @@ def proof(scope, state): event_id, entry_id = row['eventId'], row['entryId'] if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id) or not re.fullmatch(r'[1-9][0-9]{0,19}', entry_id): raise RuntimeError('INVALID_EVENT_IDENTITY') - event_type = 'OverseasDeliveryCompleted' if delivery(state) else 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted' + event_type = 'BusinessFeeSourceChanged' if fee_void(state) else 'OverseasDeliveryCompleted' if delivery(state) else 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted' attempts = read_sql(inspect(producer_name(state)), "SELECT JSON_OBJECT('attemptId',attempt_id,'serviceName',service_name,'entryId',entry_id,'eventId',event_id,'targetName',target_name,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='" + entry_id + "' AND event_type='" + event_type + "' ORDER BY started_at,attempt_id;") @@ -383,7 +504,9 @@ def proof(scope, state): "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE request_id='" + state['operationsRequestId'] + "';" if not reverse_delivery(state) else "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE entry_id='" + entry_id + "' AND event_type='" + event_type + "';") - if reverse_delivery(state): + if fee_void(state): + consumers = fee_inbox(scope, row['eventId']) + elif reverse_delivery(state): consumers = read_sql(inspect('firstleg-test-business'), "SELECT JSON_OBJECT('eventId',event_id,'payloadHash',payload_hash,'status',status,'eventType',event_type,'aggregateId',aggregate_id,'payload',JSON_EXTRACT(payload_json,'$')) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';") else: @@ -395,6 +518,9 @@ def proof(scope, state): 'proxyRecords': control(state)['records'], 'beforeRetry': state['beforeRetry'], 'afterRetry': {**row, **business_snapshot(scope, state, row)}, 'retryAudit': audit[0] if len(audit) == 1 else None, 'retryAuditCount': len(audit), 'retryRequestId': state['operationsRequestId'], 'restored': False} + if fee_void(state): + result['prerequisite'] = state['prerequisite'] + result['publicationNow'] = fee_event(scope, 'PUBLISHED' if scope['sourceVersion'] == '1' else 'ADJUSTED') private_write(scope_directory(scope, state.get('direction', 'LOADING')) / 'proof.json', result) return result @@ -440,6 +566,8 @@ def main(config): scope, action = config['scope'], config['action'] direction = config.get('direction', 'LOADING') directory = scope_directory(scope, direction) + if action == 'preflight' and direction == 'FEE_VOID': + return fee_prerequisite(scope, wait=True) if action == 'prepare': return prepare(scope, directory, config) state = json.loads((directory / 'state.json').read_text()) diff --git a/scripts/test-fee-void-fault-proof.mjs b/scripts/test-fee-void-fault-proof.mjs new file mode 100644 index 000000000..e2f035fe7 --- /dev/null +++ b/scripts/test-fee-void-fault-proof.mjs @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto' +import { isDeepStrictEqual as equal } from 'node:util' + +export const feeVoidScopeKeys=['marker','customerId','sourceFeeId','sourceVersion','sourceType'] +export const feeSourceTypes=['OVERSEAS_INBOUND_FEE','OVERSEAS_PROCUREMENT_FEE','OVERSEAS_TRUCK_FEE','OVERSEAS_LOOSEBOARD_FEE', + 'OVERSEAS_WAREHOUSE_FEE','OVERSEAS_CONTAINER_FEE','OVERSEAS_ORDER_FEE','OVERSEAS_DELIVERY_FEE','OVERSEAS_RELABEL_FEE'] +const hash=value=>'sha256:'+createHash('sha256').update(value,'utf8').digest('hex') +const without=(row,keys)=>Object.fromEntries(Object.entries(row).filter(([key])=>!keys.includes(key))) +const date=value=>Date.parse(String(value).replace(' ','T').replace(/Z$/,'')+'Z') + +export function assertTestFeeVoidFaultProof(proof,scope) { + const need=(passed,code)=>{if(!passed)throw Error('Invalid fee void fault proof: '+code)} + need(proof?.readOnly===true && feeVoidScopeKeys.every(key=>typeof scope[key]==='string'&&proof[key]===scope[key]) + && Object.keys(scope).length===feeVoidScopeKeys.length && feeSourceTypes.includes(scope.sourceType) + && /^[A-Za-z0-9_-]{1,96}$/.test(scope.marker) && ['customerId','sourceFeeId','sourceVersion'].every(key=>/^[1-9][0-9]{0,19}$/.test(scope[key])) + && Number(scope.sourceVersion)<=2147483647, 'SCOPE') + const event=(row,action)=>{ + need(row && /^[1-9][0-9]{0,19}$/.test(row.entryId) && /^[0-9a-fA-F-]{36}$/.test(row.eventId), 'EVENT_IDENTITY') + const env=row.envelope,p=env?.payload + need(row.eventType==='BusinessFeeSourceChanged' && row.customerId===scope.customerId && row.aggregateId===scope.sourceFeeId + && row.aggregateType===scope.sourceType && row.eventKey===`BusinessFeeSourceChanged:LASTLEG:${scope.sourceType}:${scope.sourceFeeId}:${scope.sourceVersion}:${action}` + && env?.eventId===row.eventId && env.eventType===row.eventType && env.tenantId===scope.customerId && Number(env.eventVersion)===1 + && env.aggregateType===scope.sourceType && env.aggregateId===scope.sourceFeeId && String(env.aggregateVersion)===scope.sourceVersion + && p?.action===action && p.sourceSystem==='LASTLEG' && p.customerId===scope.customerId && p.sourceFeeId===scope.sourceFeeId + && String(p.sourceVersion)===scope.sourceVersion && p.sourceType===scope.sourceType && p.direction==='RECEIVABLE' + && p.currency==='CNY' && p.reviewStatus==='APPROVED' && p.publicationStatus==='PUBLISHED' + && p.sourceStatus===(action==='VOIDED'?'VOIDED':'ACTIVE'), 'EVENT_SCOPE') + // Runtime preserves exact decimal lexemes and applies Java BigDecimal canonicalization. + let parsed,canonical,unsigned + try {parsed=JSON.parse(row.envelopeJson);canonical=JSON.parse(row.payloadCanonicalJson);unsigned=JSON.parse(row.sourceCanonicalJson)} + catch {need(false,'CANONICAL_JSON')} + need(equal(parsed,env) && equal(canonical,p) && equal(unsigned,{...p,action:null,sourceStatus:'ACTIVE',sourceHash:null}) + && hash(row.payloadCanonicalJson)===row.payloadHash && env.payloadHash===row.payloadHash + && hash(row.sourceCanonicalJson)===p.sourceHash, 'PAYLOAD_HASH') + return p + } + const row=proof.producer,payload=event(row,'VOIDED'),prior=proof.prerequisite,publication=prior?.publication + const original=event(publication,scope.sourceVersion==='1'?'PUBLISHED':'ADJUSTED') + need(prior.ready===true && publication.publishStatus==='PUBLISHED' && equal(publication,proof.publicationNow) + && publication.eventId!==row.eventId && publication.entryId!==row.entryId + && publication.sourceCanonicalJson===row.sourceCanonicalJson && original.sourceHash===payload.sourceHash + && prior.attempts?.some(a=>a.entryId===publication.entryId&&a.eventId===publication.eventId&&['APPLIED','DUPLICATE'].includes(a.outcome)), 'PUBLICATION_PREREQUISITE') + const before=proof.beforeRetry,after=proof.afterRetry + need(before?.publishStatus==='DEAD' && before.failedAttempts===12 && after?.publishStatus==='PUBLISHED' && row.publishStatus==='PUBLISHED' + && [before,after].every(value=>value.entryId===row.entryId && value.eventId===row.eventId && value.payloadHash===row.payloadHash + && equal(value.envelope,row.envelope) && value.envelopeJson===row.envelopeJson + && value.payloadCanonicalJson===row.payloadCanonicalJson && value.sourceCanonicalJson===row.sourceCanonicalJson), 'ORIGINAL_RETRY') + const attempts=proof.attempts,records=proof.proxyRecords?.filter(value=>value.matched===true) + need(attempts?.length===13 && new Set(attempts.map(a=>a.attemptId)).size===13 && attempts.every((a,i)=>a.entryId===row.entryId + && a.eventId===row.eventId && a.serviceName==='lastleg' && a.outcome===(i<11?'RETRY':i===11?'DEAD':'DUPLICATE')), 'ATTEMPTS') + need(records?.length===13 && records.every((a,i)=>a.eventId===row.eventId && a.payloadHash===row.payloadHash && a.attempt===i+1 + && a.outcome===(i===0?'APPLIED':'DUPLICATE') && (i<12?a.errorCode==='ACK_LOSS_INJECTED':a.errorCode===null)), 'REAL_ACK_LOSS') + const audit=proof.retryAudit + need(proof.retryAuditCount===1 && audit?.requestId===proof.retryRequestId && audit.entryId===row.entryId && audit.eventId===row.eventId + && audit.payloadHash===row.payloadHash && audit.previousFailures===12, 'RETRY_AUDIT') + const initial=prior.business,dead=before.fee,done=after.fee + need(initial && dead && done && equal(dead.finance,done.finance), 'FINANCE_CHANGED_AFTER_DUPLICATE') + const selected=(facts,local)=>{ + const rows=local?facts.lastleg:facts.finance?.source + need(Array.isArray(rows)&&rows.length>0&&new Set(rows.map(a=>a.id)).size===rows.length, 'SOURCE_ROWS') + need(rows.every(a=>a.customerId===scope.customerId && a.sourceType===scope.sourceType && a.sourceFeeId===scope.sourceFeeId), 'SOURCE_SCOPE') + const result=rows.filter(a=>String(local?a.sourceVersion:a.sourceFeeVersion)===scope.sourceVersion) + need(result.length===1, 'UNIQUE_SOURCE_VERSION') + need(Math.max(...rows.map(a=>Number(local?a.sourceVersion:a.sourceFeeVersion)))===Number(scope.sourceVersion), 'LATEST_VERSION') + return result[0] + } + const l0=selected(initial,true),ld=selected(dead,true),la=selected(done,true) + const f0=selected(initial,false),fd=selected(dead,false) + need(l0.sourceStatus==='ACTIVE' && ld.sourceStatus==='ACTIVE' && la.sourceStatus==='VOIDED' + && [l0,ld,la].every(a=>a.reviewStatus==='APPROVED'&&a.publicationStatus==='PUBLISHED'&&a.direction==='RECEIVABLE'), 'LASTLEG_ACK_BOUNDARY') + need(equal(without(l0,['reviewReason','updatedBy','updatedAt']),without(ld,['reviewReason','updatedBy','updatedAt'])) + && equal(without(ld,['sourceStatus','updatedAt']),without(la,['sourceStatus','updatedAt'])) + && typeof ld.reviewReason==='string' && ld.reviewReason.trim() && date(la.updatedAt)>=date(ld.updatedAt), 'LASTLEG_UNEXPECTED_CHANGE') + need(equal(initial.lastleg.filter(a=>a.id!==l0.id),dead.lastleg.filter(a=>a.id!==ld.id)) + && equal(dead.lastleg.filter(a=>a.id!==ld.id),done.lastleg.filter(a=>a.id!==la.id)), 'OTHER_LOCAL_VERSIONS_CHANGED') + need(f0.status==='AVAILABLE' && fd.status==='VOIDED' && Number(fd.operationVersion)===Number(f0.operationVersion)+1 + && equal(without(f0,['status','operationVersion','updatedAt']),without(fd,['status','operationVersion','updatedAt'])) + && f0.sourceHash===payload.sourceHash && f0.direction==='RECEIVABLE' && f0.currencyCode==='CNY' + && f0.amount===l0.amount && f0.walletHoldId===null && equal(f0.sourceSnapshot,original), 'FINANCE_VOID_FACT') + need(equal(initial.finance.source.filter(a=>a.id!==f0.id),dead.finance.source.filter(a=>a.id!==fd.id)), 'OTHER_FINANCE_VERSIONS_CHANGED') + for(const facts of [initial,dead,done]) for(const key of ['bill','settlement','cost']) need(Array.isArray(facts.finance[key])&&facts.finance[key].length===0,'FINANCIAL_LINKS') + const checkInbox=(rows,producer,resource)=>{ + need(rows?.length===1 && rows[0].sourceSystem==='LASTLEG' && rows[0].eventType==='BusinessFeeSourceChanged' + && rows[0].eventId===producer.eventId && rows[0].payloadHash===producer.payloadHash && rows[0].status==='PROCESSED' + && rows[0].resultResourceId===resource.id && rows[0].processedAt && rows[0].errorCode===null + && equal(rows[0].envelope,producer.envelope), 'FINANCE_INBOX') + } + checkInbox(initial.finance.inbox,publication,f0); checkInbox(dead.finance.inbox,row,fd) + need(proof.consumer?.count===1 && equal(without(proof.consumer,['count']),dead.finance.inbox[0]), 'CONSUMER_IDENTITY') + return proof +} diff --git a/scripts/test-fee-void-fault-proof.test.mjs b/scripts/test-fee-void-fault-proof.test.mjs new file mode 100644 index 000000000..0f6c8b42a --- /dev/null +++ b/scripts/test-fee-void-fault-proof.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createTestFeeVoidFaultExecutor } from './test-event-fault-executor.mjs' +import { assertTestFeeVoidFaultProof } from './test-fee-void-fault-proof.mjs' + +const scope={marker:'FEE-VOID-OFFLINE',customerId:'7',sourceFeeId:'123',sourceVersion:'1',sourceType:'OVERSEAS_WAREHOUSE_FEE'} +const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://jenkins.example.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'offline',FBA_CORE_JENKINS_AGENT:'java-test-agent'} +const sort=value=>Array.isArray(value)?value.map(sort):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,sort(value[key])])):value +const text=value=>JSON.stringify(sort(value)), hash=value=>'sha256:'+createHash('sha256').update(value).digest('hex') +const clone=value=>JSON.parse(JSON.stringify(value)) +const imageId='sha256:'+'a'.repeat(64),fingerprint='b'.repeat(64) +const restoration={restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId:imageId,currentImageId:imageId,originalFingerprint:fingerprint,currentFingerprint:fingerprint} +function fixture() { + const base={action:null,sourceSystem:'LASTLEG',sourceType:scope.sourceType,sourceFeeId:'123',sourceVersion:1,customerId:'7',customerName:'Offline', + sourceNo:'WH-OFFLINE',businessType:'WAREHOUSE_SERVICE',businessId:'200',businessNo:'WH-OFFLINE',orderId:null,inboundId:null,inboundCode:null, + containerId:null,containerBookingId:null,containerNo:null,warehouseId:'9',feeItemCode:'TEST',feeItemName:'Offline fee',direction:'RECEIVABLE', + supplierId:null,supplierName:null,supplierCategory:null,settlementMode:null,paidAt:null,paymentMethod:null,paymentReferenceNo:null,paymentVoucherFileIds:[], + businessDate:'2026-10-03',currency:'CNY',totalAmount:7.25,originalCurrency:'CNY',originalAmount:7.25,exchangeRate:1,exchangeRateMonth:'2026-10', + chargeableQuantity:null,quantityUnit:null,unitPrice:null,periodStart:null,periodEnd:null,reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',sourceStatus:'ACTIVE',sourceHash:null} + const sourceCanonicalJson=text(base),sourceHash=hash(sourceCanonicalJson) + const event=(action,index)=>{ + const payload={...base,action,sourceStatus:action==='VOIDED'?'VOIDED':'ACTIVE',sourceHash} + const payloadCanonicalJson=text(payload),payloadHash=hash(payloadCanonicalJson) + const eventId=`00000000-0000-4000-8000-00000000000${index}` + const envelope={eventId,eventType:'BusinessFeeSourceChanged',eventVersion:1,tenantId:'7',aggregateType:scope.sourceType,aggregateId:'123',aggregateVersion:1,payload,payloadHash} + return {entryId:String(index),eventId,payloadHash,eventType:envelope.eventType,customerId:'7',aggregateId:'123',aggregateType:scope.sourceType, + eventKey:`BusinessFeeSourceChanged:LASTLEG:${scope.sourceType}:123:1:${action}`,envelope,envelopeJson:JSON.stringify(envelope),payloadCanonicalJson,sourceCanonicalJson,publishStatus:'PUBLISHED',failedAttempts:0} + } + const publication=event('PUBLISHED',1),producer=event('VOIDED',2) + const local={id:'500',sourceFeeId:'123',sourceVersion:'1',customerId:'7',sourceType:scope.sourceType,sourceStatus:'ACTIVE',reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',direction:'RECEIVABLE',amount:'7.250000',businessType:'WAREHOUSE_SERVICE',businessId:'200',reviewReason:'approved',updatedBy:'7',updatedAt:'2026-10-03 01:00:00'} + const finance={id:'600',sourceSystem:'LASTLEG',sourceFeeId:'123',sourceFeeVersion:'1',customerId:'7',sourceType:scope.sourceType,direction:'RECEIVABLE',currencyCode:'CNY',amount:'7.250000',walletHoldId:null,operationVersion:'0',status:'AVAILABLE',sourceHash,sourceSnapshot:publication.envelope.payload,updatedAt:'2026-10-03 01:00:01'} + const inbox=(row,id)=>({id,eventId:row.eventId,sourceSystem:'LASTLEG',eventType:'BusinessFeeSourceChanged',envelope:row.envelope,payloadHash:row.payloadHash,resultResourceId:'600',status:'PROCESSED',errorCode:null,receivedAt:'2026-10-03 01:00:01',processedAt:'2026-10-03 01:00:01'}) + const initial={lastleg:[local],finance:{source:[finance],bill:[],settlement:[],cost:[],inbox:[inbox(publication,'700')]}} + const dead=clone(initial);dead.lastleg[0].reviewReason='void reason';dead.lastleg[0].updatedAt='2026-10-03 01:01:00' + Object.assign(dead.finance.source[0],{status:'VOIDED',operationVersion:'1',updatedAt:'2026-10-03 01:01:01'});dead.finance.inbox=[inbox(producer,'701')] + const after=clone(dead);Object.assign(after.lastleg[0],{sourceStatus:'VOIDED',updatedAt:'2026-10-03 01:02:00'}) + const prior={ready:true,publication,business:initial,attempts:[{entryId:'1',eventId:publication.eventId,outcome:'APPLIED'}]} + const requestId='00000000-0000-4000-8000-000000000003' + return clone({...scope,readOnly:true,producer,prerequisite:prior,publicationNow:publication, + beforeRetry:{...producer,publishStatus:'DEAD',failedAttempts:12,fee:dead},afterRetry:{...producer,fee:after}, + consumer:{...dead.finance.inbox[0],count:1}, + attempts:Array.from({length:13},(_,i)=>({attemptId:`a${i}`,entryId:'2',eventId:producer.eventId,serviceName:'lastleg',outcome:i<11?'RETRY':i===11?'DEAD':'DUPLICATE'})), + proxyRecords:Array.from({length:13},(_,i)=>({matched:true,attempt:i+1,eventId:producer.eventId,payloadHash:producer.payloadHash,outcome:i===0?'APPLIED':'DUPLICATE',errorCode:i<12?'ACK_LOSS_INJECTED':null})), + retryAuditCount:1,retryRequestId:requestId,retryAudit:{requestId,entryId:'2',eventId:producer.eventId,payloadHash:producer.payloadHash,previousFailures:12}}) +} +function config(options) { + const chunks=[...options.body.get('script').matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(m=>m[1]).join('') + const groovy=Buffer.from(chunks,'base64').toString('utf8'),encoded=groovy.match(/'UTF-8'\),'([A-Za-z0-9+/=]+)'\]\)/)?.[1] + return JSON.parse(Buffer.from(encoded,'base64').toString('utf8')) +} +function transport(calls,{pending=false,preflightFailure=false,proof=fixture(),finish={}}={}) { + let recovered=false,preflightCount=0 + return async(_url,options)=>{ + const request=config(options);assert.equal(request.direction,'FEE_VOID');assert.deepEqual(request.scope,scope) + const action=request.action;calls.push(action) + if(action==='recover')recovered=true + if(action==='preflight')preflightCount++ + const data=action==='preflight'?{ready:!pending||preflightCount>1}:action==='prepare'?{originalImageId:imageId,originalFingerprint:fingerprint,prerequisite:proof.prerequisite} + :action==='proxy-ready'?{ready:true}:action==='health'?{healthy:true}:action==='status'?{producer:{publishStatus:recovered?'PUBLISHED':'DEAD',failedAttempts:12}} + :action==='proof'?proof:action==='finish'?{...restoration,...finish}:{} + const result=preflightFailure&&action==='preflight'?{ok:false,error:'FEE_PUBLICATION_NOT_READY'}:{ok:true,data} + return {ok:true,text:async()=> 'TEST_EVENT_JSON='+JSON.stringify(result)} + } +} +test('VOID proof accepts Finance VOIDED at DEAD while Lastleg remains ACTIVE until matched retry ACK',()=>{ + const proof=fixture();assertTestFeeVoidFaultProof(proof,scope) + assert.equal(proof.beforeRetry.fee.lastleg[0].sourceStatus,'ACTIVE') + assert.equal(proof.beforeRetry.fee.finance.source[0].status,'VOIDED') + assert.equal(proof.afterRetry.fee.lastleg[0].sourceStatus,'VOIDED') +}) +test('VOID proof rejects premature local confirmation, extra financial writes, altered source and lost original event evidence',()=>{ + const mutations=[p=>p.beforeRetry.fee.lastleg[0].sourceStatus='VOIDED',p=>p.afterRetry.fee.lastleg[0].sourceStatus='ACTIVE', + p=>p.afterRetry.fee.lastleg[0].amount='8.250000',p=>p.afterRetry.fee.lastleg[0].sourceVersion='2', + p=>p.afterRetry.fee.finance.source[0].operationVersion='2',p=>p.beforeRetry.fee.finance.source[0].status='AVAILABLE', + p=>p.prerequisite.publication.publishStatus='PENDING',p=>p.prerequisite.business.finance.source[0].walletHoldId='99', + p=>p.afterRetry.fee.finance.bill.push({id:'123'}),p=>p.consumer.resultResourceId='601', + p=>p.producer.envelope.payload.sourceFeeId='124',p=>p.beforeRetry.entryId='9',p=>p.retryAuditCount=2, + p=>p.proxyRecords[0].outcome='DUPLICATE',p=>p.attempts.pop(),p=>p.producer.sourceCanonicalJson='{}', + p=>p.prerequisite.business.lastleg.push({...p.prerequisite.business.lastleg[0],id:'501',sourceVersion:'2'})] + for(const mutate of mutations){const proof=fixture();mutate(proof);assert.throws(()=>assertTestFeeVoidFaultProof(proof,scope),/Invalid fee void/)} +}) +test('preflight waits for original publish ACK before a single prepare; restoration precedes successful proof',async()=>{ + const calls=[],executor=createTestFeeVoidFaultExecutor(env,transport(calls,{pending:true})) + for(const invalid of [{...scope,sourceVersion:1},{...scope,sourceType:'UNKNOWN'},{...scope,sourceVersion:'2147483648'},{...scope,action:'VOIDED'}]) + await assert.rejects(executor.prepare(invalid),/Invalid/) + await executor.prepare(scope) + const proof=await executor.readProof(scope);assert.equal(proof.restored,true) + assert.deepEqual(calls,['preflight','preflight','prepare','proxy-ready','activate','health','status','recover','status','proof','restore','health','finish']) + await executor.cleanup();assert.equal(calls.filter(a=>a==='prepare').length,1) +}) +test('preflight failure never mutates environment and cannot silently reuse an unprepared executor',async()=>{ + const calls=[],executor=createTestFeeVoidFaultExecutor(env,transport(calls,{preflightFailure:true})) + await assert.rejects(executor.prepare(scope),/FEE_PUBLICATION_NOT_READY/) + assert.deepEqual(await executor.cleanup(),{attempted:false});await assert.rejects(executor.prepare(scope),/did not complete/) + assert.deepEqual(calls,['preflight']) +}) +test('proof failure restores; missing original configuration or proxy stop remains a blocker',async()=>{ + const proof=fixture();proof.afterRetry.fee.finance.source[0].operationVersion='2' + const calls=[],executor=createTestFeeVoidFaultExecutor(env,transport(calls,{proof})) + await executor.prepare(scope);await assert.rejects(executor.readProof(scope),/FINANCE_CHANGED/) + assert.deepEqual(calls.slice(-3),['restore','health','finish']) + for(const finish of [{proxyStopped:false},{currentFingerprint:'c'.repeat(64)}]) { + const failed=createTestFeeVoidFaultExecutor(env,transport([],{finish})) + await failed.prepare(scope);await assert.rejects(failed.readProof(scope),/Fee void restoration/) + } +}) + +test('unknown prepare result is never reissued and cleanup still attempts original restoration',async()=>{ + const calls=[],base=transport(calls) + const executor=createTestFeeVoidFaultExecutor(env,async(url,options)=>{ + if(config(options).action==='prepare'){calls.push('prepare');throw Error('response lost')} + return base(url,options) + }) + await assert.rejects(executor.prepare(scope),/response lost/) + await assert.rejects(executor.prepare(scope),/did not complete/) + await assert.rejects(executor.cleanup(),/Fee void restoration evidence mismatch/) + assert.deepEqual(calls,['preflight','prepare','restore','health','finish']) +}) + +test('Python FEE_VOID routing, exact decimal hashes, preflight gating and restore reentry are offline',()=>{ + const result=spawnSync('python',['-c',String.raw` +import sys,importlib.util,pathlib,copy,decimal,ast,io +from unittest.mock import patch,mock_open +sys.dont_write_bytecode=True +p=pathlib.Path('scripts/test-event-fault-runtime.py');ast.parse(p.read_text(encoding='utf-8')) +spec=importlib.util.spec_from_file_location('fault',p);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +s={'marker':'FEE-VOID-OFFLINE','customerId':'7','sourceFeeId':'123','sourceVersion':'1','sourceType':'OVERSEAS_WAREHOUSE_FEE'} +assert m.target_setting({'direction':'FEE_VOID'})==('FINANCE_SERVICE_URL','http://finance:9203') +assert m.target_setting({'direction':'DELIVERY'})==('FIRSTLEG_SERVICE_URL','http://firstleg:9202') +assert m.producer_name({'direction':'FEE_VOID'})=='firstleg-test-lastleg' +assert m.fee_canonical(decimal.Decimal('100.000000'))=='1E+2' +assert m.fee_canonical(decimal.Decimal('0.00000010'))=='1E-7' +assert m.fee_canonical(decimal.Decimal('12345678901234.123456'))=='12345678901234.123456' +assert m.fee_canonical(decimal.Decimal('-0.000'))=='0' +queries=[] +with patch.object(m,'inspect',side_effect=lambda name:{'Name':'/'+name}),patch.object(m,'require_test'),patch.object(m,'read_sql',side_effect=lambda c,sql:queries.append((c['Name'],sql)) or []): + assert m.producer(s,'FEE_VOID') is None + m.fee_facts(s,'00000000-0000-4000-8000-000000000002') +assert [r[0] for r in queries]==['/firstleg-test-lastleg','/firstleg-test-lastleg','/firstleg-test-finance','/firstleg-test-finance'] +assert 'BusinessFeeSourceChanged:LASTLEG:OVERSEAS_WAREHOUSE_FEE:123:1:VOIDED' in queries[0][1] +assert 'void_status' not in queries[1][1] and 'source_status' in queries[1][1] +assert all(t in queries[2][1] for t in ['fin_fee_source','fin_bill_line','fin_settlement_line','fin_cost','operation_version']) +assert 'fin_inbox_event' in queries[3][1] and 'result_resource_id' in queries[3][1] +publication={'entryId':'1','eventId':'00000000-0000-4000-8000-000000000001','publishStatus':'PENDING','payloadHash':'sha256:'+'a'*64} +facts={'lastleg':[{'sourceVersion':'1','sourceType':s['sourceType'],'customerId':'7','sourceStatus':'ACTIVE','reviewStatus':'APPROVED','publicationStatus':'PUBLISHED','direction':'RECEIVABLE'}], + 'finance':{'source':[{'id':'600','sourceFeeVersion':'1','sourceType':s['sourceType'],'customerId':'7','status':'AVAILABLE','walletHoldId':None}], + 'bill':[],'settlement':[],'cost':[],'inbox':[{'status':'PROCESSED','payloadHash':publication['payloadHash'],'resultResourceId':'600'}]}} +with patch.object(m,'fee_event',side_effect=lambda scope,action:None if action=='VOIDED' else publication),patch.object(m,'fee_facts',return_value=facts),patch.object(m,'inspect'),patch.object(m,'read_sql',return_value=[{'eventId':publication['eventId'],'outcome':'APPLIED'}]): + assert m.fee_prerequisite(s,wait=True)['ready'] is False + publication['publishStatus']='PUBLISHED';assert m.fee_prerequisite(s,wait=True)['ready'] is True + publication['publishStatus']='DEAD' + try:m.fee_prerequisite(s,wait=True);assert False + except RuntimeError as e:assert str(e)=='FEE_PUBLICATION_NOT_READY' +original={'Name':'/firstleg-test-lastleg','Id':'original','Image':'sha256:'+'a'*64,'Config':{'Image':'lastleg:test','Env':['FINANCE_SERVICE_URL=http://finance:9203','FIRSTLEG_SERVICE_URL=http://firstleg:9202'],'Labels':{'com.docker.compose.project':'firstleg-test'}},'HostConfig':{},'Mounts':[], + 'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3','Aliases':['lastleg-service']}}},'State':{'Running':True,'Health':{'Status':'healthy'}}} +finance=copy.deepcopy(original);finance['Name']='/firstleg-test-finance';finance['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress']='172.20.0.9' +with patch.object(m,'fee_prerequisite',return_value={'ready':True}),patch.object(m,'inspect',side_effect=lambda name:finance if name=='firstleg-test-finance' else original),patch.object(m.Path,'mkdir'),patch.object(m.Path,'write_bytes'),patch('builtins.open',mock_open()),patch.object(m.socket,'socket') as sock,patch.object(m.subprocess,'Popen') as process,patch.object(m,'private_write'): + sock.return_value.getsockname.return_value=('127.0.0.1',18001) + m.prepare(s,pathlib.Path('/offline'),{'direction':'FEE_VOID','proxySource':''}) + args=process.call_args.args[0] + assert args[args.index('--upstream')+1]=='http://172.20.0.9:9203/internal/finance/v1/business-fee-source-events' + assert args[args.index('--fee-source-id')+1]=='123' and args[args.index('--source-version')+1]=='1' + assert '--delivery-id' not in args and '--container-booking-id' not in args +state={'direction':'FEE_VOID','restored':False,'prepared':True,'originalId':'original','image':'lastleg:test','originalImageId':original['Image'],'originalFingerprint':m.fingerprint(original),'originalBody':m.recreation(original,m.env_of(original),full=True),'proxyPort':18001} +with patch.object(m,'control',return_value={}),patch.object(m,'inspect',return_value=original),patch.object(m,'private_write'),patch.object(m,'replace') as replace: + m.activate(s,pathlib.Path('/offline'),copy.deepcopy(state)) + changed=dict(item.split('=',1) for item in replace.call_args.args[1]['Env']) + assert changed['FINANCE_SERVICE_URL']=='http://host.docker.internal:18001' + assert changed['FIRSTLEG_SERVICE_URL']=='http://firstleg:9202' and changed['EVENT_MAX_ATTEMPTS']=='12' +row={'entryId':'2','eventId':'00000000-0000-4000-8000-000000000002','payloadHash':'sha256:'+'b'*64,'publishStatus':'DEAD','failedAttempts':12} +retry_state={**state,'operationsRequestId':'00000000-0000-4000-8000-000000000003'} +runtime=copy.deepcopy(original);runtime['Config']['Env'].append('EVENT_OPERATIONS_TOKEN=offline') +dead={'lastleg':[{'sourceVersion':'1','sourceStatus':'ACTIVE'}],'finance':{'source':[{'sourceFeeVersion':'1','status':'VOIDED'}]}} +with patch.object(m,'producer',return_value=row),patch.object(m,'control',return_value=row),patch.object(m,'fee_facts',return_value=dead),patch.object(m,'private_write'),patch.object(m,'inspect',return_value=runtime),patch.object(m.urllib.request,'urlopen',return_value=io.BytesIO(b'{"code":"200"}')) as http: + assert m.recover(s,pathlib.Path('/offline'),retry_state)['retrySubmitted'] + assert http.call_args.args[0].full_url=='http://172.20.0.3:9303/internal/lastleg/event-operations/2/retry' + assert retry_state['beforeRetry']['fee']['lastleg'][0]['sourceStatus']=='ACTIVE' +restored=copy.deepcopy(original);restored['Id']='restored' +with patch.object(m,'control',side_effect=OSError('stopped')),patch.object(m,'inspect',return_value=restored),patch.object(m,'private_write'),patch.object(m,'replace') as replace: + m.restore(pathlib.Path('/offline'),state);assert not state['prepared'];replace.assert_not_called() +state['prepared']=True;restored['Config']['Env'].append('UNEXPECTED=1') +with patch.object(m,'control'),patch.object(m,'inspect',return_value=restored): + try:m.restore(pathlib.Path('/offline'),state);assert False + except RuntimeError as e:assert str(e)=='CONCURRENT_PRODUCER_CHANGE' +`],{cwd:new URL('..',import.meta.url),encoding:'utf8',timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) -- 2.54.0 From 588227f181b754d070a32b7e3d64433936def2d3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 09:10:34 +0800 Subject: [PATCH 086/176] test: match non-null fee source hash serialization --- scripts/test-event-fault-runtime.py | 8 +++- scripts/test-fee-void-fault-proof.mjs | 7 ++-- scripts/test-fee-void-fault-proof.test.mjs | 44 +++++++++++++++++++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/scripts/test-event-fault-runtime.py b/scripts/test-event-fault-runtime.py index de8b5c469..e7be87886 100644 --- a/scripts/test-event-fault-runtime.py +++ b/scripts/test-event-fault-runtime.py @@ -259,10 +259,14 @@ def fee_event(scope, action): row = rows[0] payload = json.loads(row['envelopeJson'], parse_float=decimal.Decimal, parse_int=decimal.Decimal)['payload'] row['payloadCanonicalJson'] = fee_canonical(payload) - unsigned = {**payload, 'action': None, 'sourceStatus': 'ACTIVE', 'sourceHash': None} + # The shared NON_NULL ObjectMapper omits these unsigned DTO fields. + unsigned = {key: value for key, value in payload.items() if key not in ('action', 'sourceHash')} + unsigned['sourceStatus'] = 'ACTIVE' row['sourceCanonicalJson'] = fee_canonical(unsigned) - if hashlib.sha256(row['payloadCanonicalJson'].encode()).hexdigest() != row['payloadHash'].removeprefix('sha256:') or payload.get('sourceHash') != 'sha256:' + hashlib.sha256(row['sourceCanonicalJson'].encode()).hexdigest(): + if hashlib.sha256(row['payloadCanonicalJson'].encode()).hexdigest() != row['payloadHash'].removeprefix('sha256:'): raise RuntimeError('FEE_PAYLOAD_HASH_MISMATCH') + if payload.get('sourceHash') != 'sha256:' + hashlib.sha256(row['sourceCanonicalJson'].encode()).hexdigest(): + raise RuntimeError('FEE_SOURCE_HASH_MISMATCH') if row['customerId'] != scope['customerId'] or row['aggregateId'] != scope['sourceFeeId'] or row['aggregateType'] != scope['sourceType']: raise RuntimeError('FEE_EVENT_SCOPE_MISMATCH') if payload.get('sourceSystem') != 'LASTLEG' or payload.get('customerId') != scope['customerId'] or payload.get('sourceFeeId') != scope['sourceFeeId'] or str(payload.get('sourceVersion')) != scope['sourceVersion'] or payload.get('sourceType') != scope['sourceType'] or payload.get('action') != action: diff --git a/scripts/test-fee-void-fault-proof.mjs b/scripts/test-fee-void-fault-proof.mjs index e2f035fe7..78c96a4bc 100644 --- a/scripts/test-fee-void-fault-proof.mjs +++ b/scripts/test-fee-void-fault-proof.mjs @@ -29,9 +29,10 @@ export function assertTestFeeVoidFaultProof(proof,scope) { let parsed,canonical,unsigned try {parsed=JSON.parse(row.envelopeJson);canonical=JSON.parse(row.payloadCanonicalJson);unsigned=JSON.parse(row.sourceCanonicalJson)} catch {need(false,'CANONICAL_JSON')} - need(equal(parsed,env) && equal(canonical,p) && equal(unsigned,{...p,action:null,sourceStatus:'ACTIVE',sourceHash:null}) - && hash(row.payloadCanonicalJson)===row.payloadHash && env.payloadHash===row.payloadHash - && hash(row.sourceCanonicalJson)===p.sourceHash, 'PAYLOAD_HASH') + need(equal(parsed,env) && equal(canonical,p) + && hash(row.payloadCanonicalJson)===row.payloadHash && env.payloadHash===row.payloadHash, 'PAYLOAD_HASH') + need(equal(unsigned,{...without(p,['action','sourceHash']),sourceStatus:'ACTIVE'}) + && hash(row.sourceCanonicalJson)===p.sourceHash, 'SOURCE_HASH') return p } const row=proof.producer,payload=event(row,'VOIDED'),prior=proof.prerequisite,publication=prior?.publication diff --git a/scripts/test-fee-void-fault-proof.test.mjs b/scripts/test-fee-void-fault-proof.test.mjs index 0f6c8b42a..2a3a9e51c 100644 --- a/scripts/test-fee-void-fault-proof.test.mjs +++ b/scripts/test-fee-void-fault-proof.test.mjs @@ -13,12 +13,12 @@ const clone=value=>JSON.parse(JSON.stringify(value)) const imageId='sha256:'+'a'.repeat(64),fingerprint='b'.repeat(64) const restoration={restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId:imageId,currentImageId:imageId,originalFingerprint:fingerprint,currentFingerprint:fingerprint} function fixture() { - const base={action:null,sourceSystem:'LASTLEG',sourceType:scope.sourceType,sourceFeeId:'123',sourceVersion:1,customerId:'7',customerName:'Offline', + const base=Object.fromEntries(Object.entries({action:null,sourceSystem:'LASTLEG',sourceType:scope.sourceType,sourceFeeId:'123',sourceVersion:1,customerId:'7',customerName:'Offline', sourceNo:'WH-OFFLINE',businessType:'WAREHOUSE_SERVICE',businessId:'200',businessNo:'WH-OFFLINE',orderId:null,inboundId:null,inboundCode:null, containerId:null,containerBookingId:null,containerNo:null,warehouseId:'9',feeItemCode:'TEST',feeItemName:'Offline fee',direction:'RECEIVABLE', supplierId:null,supplierName:null,supplierCategory:null,settlementMode:null,paidAt:null,paymentMethod:null,paymentReferenceNo:null,paymentVoucherFileIds:[], businessDate:'2026-10-03',currency:'CNY',totalAmount:7.25,originalCurrency:'CNY',originalAmount:7.25,exchangeRate:1,exchangeRateMonth:'2026-10', - chargeableQuantity:null,quantityUnit:null,unitPrice:null,periodStart:null,periodEnd:null,reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',sourceStatus:'ACTIVE',sourceHash:null} + chargeableQuantity:null,quantityUnit:null,unitPrice:null,periodStart:null,periodEnd:null,reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',sourceStatus:'ACTIVE',sourceHash:null}).filter(([,value])=>value!==null)) const sourceCanonicalJson=text(base),sourceHash=hash(sourceCanonicalJson) const event=(action,index)=>{ const payload={...base,action,sourceStatus:action==='VOIDED'?'VOIDED':'ACTIVE',sourceHash} @@ -119,6 +119,46 @@ test('unknown prepare result is never reissued and cleanup still attempts origin assert.deepEqual(calls,['preflight','prepare','restore','health','finish']) }) +test('NON_NULL source hashes agree across runtime and proof and reject explicit null unsigned fields',()=>{ + const proof=fixture(),publication=proof.prerequisite.publication + const unsigned=JSON.parse(publication.sourceCanonicalJson) + assert.equal(Object.hasOwn(unsigned,'action'),false) + assert.equal(Object.hasOwn(unsigned,'sourceHash'),false) + assert.equal(Object.hasOwn(unsigned,'orderId'),false) + assert.deepEqual(unsigned.paymentVoucherFileIds,[]) + const invalid=clone(proof) + invalid.producer.sourceCanonicalJson=text({...unsigned,action:null,sourceHash:null}) + invalid.producer.envelope.payload.sourceHash=hash(invalid.producer.sourceCanonicalJson) + invalid.producer.payloadCanonicalJson=text(invalid.producer.envelope.payload) + invalid.producer.payloadHash=hash(invalid.producer.payloadCanonicalJson) + invalid.producer.envelope.payloadHash=invalid.producer.payloadHash + invalid.producer.envelopeJson=JSON.stringify(invalid.producer.envelope) + assert.throws(()=>assertTestFeeVoidFaultProof(invalid,scope),/SOURCE_HASH/) + const result=spawnSync('python',['-c',String.raw` +import sys,importlib.util,json,copy,hashlib +from unittest.mock import patch +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('fault','scripts/test-event-fault-runtime.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +data=json.load(sys.stdin);scope=data['scope'];row=data['row'] +with patch.object(m,'inspect'),patch.object(m,'read_sql',side_effect=lambda *args:[copy.deepcopy(row)]): + result=m.fee_event(scope,'PUBLISHED') + assert result['sourceCanonicalJson']==row['sourceCanonicalJson'] + assert result['payloadCanonicalJson']==row['payloadCanonicalJson'] + payload=json.loads(row['envelopeJson'])['payload'] + unsigned={**payload,'action':None,'sourceHash':None,'sourceStatus':'ACTIVE'} + payload['sourceHash']='sha256:'+hashlib.sha256(m.fee_canonical(unsigned).encode()).hexdigest() + envelope=json.loads(row['envelopeJson']);envelope['payload']=payload + row['envelopeJson']=json.dumps(envelope) + row['payloadHash']='sha256:'+hashlib.sha256(m.fee_canonical(payload).encode()).hexdigest() + try:m.fee_event(scope,'PUBLISHED');assert False + except RuntimeError as e:assert str(e)=='FEE_SOURCE_HASH_MISMATCH' + row['payloadHash']='sha256:'+'0'*64 + try:m.fee_event(scope,'PUBLISHED');assert False + except RuntimeError as e:assert str(e)=='FEE_PAYLOAD_HASH_MISMATCH' +`],{cwd:new URL('..',import.meta.url),input:JSON.stringify({scope,row:publication}),encoding:'utf8',timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) + test('Python FEE_VOID routing, exact decimal hashes, preflight gating and restore reentry are offline',()=>{ const result=spawnSync('python',['-c',String.raw` import sys,importlib.util,pathlib,copy,decimal,ast,io -- 2.54.0 From d93185e7504ccb4ca1ed95b884df053cab23efd0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 09:27:46 +0800 Subject: [PATCH 087/176] fix: schedule manual outbox retries in UTC --- .../common/core/outbox/OutboxOperations.java | 4 +- .../common/core/OutboxOperationsTest.java | 34 ++++++++++++ ...verseasFeeCancellationPersistenceTest.java | 53 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java index cdeaf1f48..f6f0664a5 100644 --- a/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java @@ -7,6 +7,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.Set; @@ -76,7 +77,8 @@ public final class OutboxOperations { return result(canonicalId, identity, request, true); } if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件"); - LocalDateTime now = LocalDateTime.now(); + // Relay due-time comparisons use UTC, independently of the JVM default zone. + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn() + "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE " + layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId); diff --git a/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java index 43096352d..2570a4853 100644 --- a/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java +++ b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java @@ -5,15 +5,20 @@ import com.baiyue.common.core.outbox.OutboxOperations; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.Set; +import java.util.TimeZone; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.*; +@Isolated("UTC retry regression temporarily changes the JVM default time zone") class OutboxOperationsTest { private static final String HASH = "sha256:" + "a".repeat(64); private JdbcTemplate jdbc; @@ -54,6 +59,35 @@ class OutboxOperationsTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero(); } + @Test void retryIsImmediatelyDueInUtcUnderShanghaiDefaultZoneAndReplayKeepsTimes() { + TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + var request = request(); + LocalDateTime before = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1); + assertThat(operations.retry("1", request).duplicate()).isFalse(); + LocalDateTime nextRetry = jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'", + (row, index) -> row.getObject(1, LocalDateTime.class)); + LocalDateTime requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", + (row, index) -> row.getObject(1, LocalDateTime.class)); + LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC); + // Allow database timestamp precision rounding, but never a default-zone offset. + assertThat(nextRetry).isBetween(before, scan.plusSeconds(1)); + assertThat(requested).isEqualTo(nextRetry); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM events WHERE id='1' AND status='PENDING' AND next_retry_at<=?", + Integer.class, scan)).isEqualTo(1); + assertThat(operations.retry("1", request).duplicate()).isTrue(); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'", + (row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(nextRetry); + assertThat(jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", + (row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(requested); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT payload_json FROM events WHERE id='1'", String.class)).isEqualTo("event-1"); + } finally { + TimeZone.setDefault(original); + } + } + @Test void staleHashAndNonDeadAndReusedRequestAreRejected() { var original = request(); var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java index 75e059060..0141b02e0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java @@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baiyue.common.core.outbox.EventDeliveryJournal; import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; @@ -14,16 +17,21 @@ import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; +import java.util.Set; +import java.util.TimeZone; import java.util.UUID; import static org.assertj.core.api.Assertions.*; +@Isolated("Manual retry regression temporarily changes the JVM default time zone") class OverseasFeeCancellationPersistenceTest { JdbcTemplate jdbc; LastlegEventOutboxMapper events; @@ -31,6 +39,7 @@ class OverseasFeeCancellationPersistenceTest { OverseasFeeCancellation cancellation; OverseasFeeOutboxRepository repository; EventDeliveryJournal journal; + OutboxOperations operations; OverseasFeeSource source; LastlegEventOutbox request; final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 1, 0); @@ -39,6 +48,7 @@ class OverseasFeeCancellationPersistenceTest { jdbc = new JdbcTemplate(dataSource); jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)"); jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))"); jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')"); var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class); @@ -49,6 +59,14 @@ class OverseasFeeCancellationPersistenceTest { cancellation = new OverseasFeeCancellation(events, fees, json); repository = new OverseasFeeOutboxRepository(events, cancellation); journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(dataSource), + new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.BUSINESS_FEE_SOURCE_CHANGED)), + payload -> { + try { + var envelope = json.readValue(payload, EventEnvelope.class).requireValid(); + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception error) { throw new IllegalStateException(error); } + }); source = fees.lockCurrent(701); var producer = new OverseasFeeOutboxService(); ReflectionTestUtils.setField(producer, "mapper", events); ReflectionTestUtils.setField(producer, "objectMapper", json); @@ -73,6 +91,41 @@ class OverseasFeeCancellationPersistenceTest { assertThat(ready).hasSize(1); assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED")); } + @Test void manualRetryInShanghaiIsImmediatelySelectableAndClaimableByUtcFeeRelay() { + TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + publishedOriginal(); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD',retry_count=12,last_error='ACK lost' WHERE id=?", request.getId()); + String entryId = String.valueOf(request.getId()); + var identity = operations.inspect(entryId); + var retry = new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "test-operator", "ACK restored", identity.payloadHash()); + assertThat(operations.retry(entryId, retry).duplicate()).isFalse(); + LocalDateTime nextRetry = events.selectById(request.getId()).getNextRetryAt(); + LocalDateTime requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", + (row, index) -> row.getObject(1, LocalDateTime.class)); + assertThat(operations.retry(entryId, retry).duplicate()).isTrue(); + assertThat(events.selectById(request.getId()).getNextRetryAt()).isEqualTo(nextRetry); + assertThat(jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", + (row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(requested); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC); + var ready = repository.pending(50, scan, scan.minusSeconds(60)); + assertThat(ready).extracting(LastlegEventOutbox::getId).containsExactly(request.getId()); + assertThat(ready.get(0).getPayloadJson()).isEqualTo(request.getPayloadJson()); + assertThat(repository.prerequisitePublished(ready.get(0))).isTrue(); + assertThat(journal.begin("utc-worker", "lastleg-service", request.getEventType(), entryId, identity.eventId(), "finance", scan, + () -> repository.claim(request.getId(), "utc-worker", scan))).isTrue(); + assertThat(repository.claimed(request.getId(), "utc-worker").getPayloadJson()).isEqualTo(request.getPayloadJson()); + assertThat(journal.complete("utc-worker", Outcome.DUPLICATE, scan, null, null, + () -> repository.published(request.getId(), "utc-worker", scan))).isTrue(); + assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PUBLISHED"); + assertThat(events.selectById(request.getId()).getPayloadJson()).isEqualTo(request.getPayloadJson()); + assertThat(status()).isEqualTo("VOIDED"); + } finally { + TimeZone.setDefault(original); + } + } @Test void journalFailureRollsBackBothLocalVoidAndOutboxCompletionThenSameAckCanCommit() { publishedOriginal(); claim(); jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); -- 2.54.0 From 30559fd4eb4e95ede7589cd722aa05b8fcc2c814 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 10:10:55 +0800 Subject: [PATCH 088/176] feat(fleet): query vehicle delivery and reassignment history --- lastleg-service/AGENTS.md | 2 + .../PlatformVehicleFulfillmentController.java | 27 +++++ .../fleet/trip/VehicleFulfillmentMapper.java | 38 +++++++ .../fleet/trip/VehicleFulfillmentModels.java | 25 +++++ .../fleet/trip/VehicleFulfillmentService.java | 53 +++++++++ .../openapi/fleet-vehicle-fulfillment-v1.yaml | 105 ++++++++++++++++++ .../trip/VehicleFulfillmentContractTest.java | 65 +++++++++++ .../trip/VehicleFulfillmentQueryTest.java | 105 ++++++++++++++++++ 8 files changed, 420 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index c9bea4623..273532344 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -75,6 +75,8 @@ ## 海外仓全流程开发路由 +- 车辆履约只读投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同 `src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml`。车辆路径下`deliveries`同时要求vehicle:list与delivery:list,`assignment-history`同时要求vehicle:list与trip:list;不新增权限码或跨库查询。计划派送归当前分配车辆,实际运输归不可变DEPART快照;客户与排车双键关联。更换读取相邻版本ASSIGN快照,首次分配不算更换,旧车/新车/仅换司机分别呈现,不按当前车牌猜历史。列表分页在只读重复读事务内核对;内部ID及整份客户快照不直接返回。 + - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java new file mode 100644 index 000000000..6bed8a9b0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}") +public class PlatformVehicleFulfillmentController { + private final VehicleFulfillmentService service; + public PlatformVehicleFulfillmentController(VehicleFulfillmentService service) { this.service = service; } + @GetMapping("/deliveries") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list') and hasAuthority('platform:overseas-warehouse:delivery:list')") + public ApiResponse> deliveries(@PathVariable @Min(1) long vehicleId, @Valid VehicleFulfillmentModels.Query query) { + return ApiResponse.success(service.deliveries(vehicleId, query)); + } + @GetMapping("/assignment-history") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list') and hasAuthority('platform:fleet:trip:list')") + public ApiResponse> changes(@PathVariable @Min(1) long vehicleId, @Valid VehicleFulfillmentModels.Query query) { + return ApiResponse.success(service.changes(vehicleId, query)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java new file mode 100644 index 000000000..c86fa011e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */ +@Mapper +public interface VehicleFulfillmentMapper { + String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + + "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})"; + String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " + + "ON b.dispatch_id=d.dispatch_order_id AND b.customer_id=d.customer_id " + + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; + String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))"; + String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; + String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id " + + "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 " + + "WHERE h.action='ASSIGN' AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' " + + "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) " + + "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE + + " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))"; + + @Select("SELECT COUNT(*)" + DELIVERIES) + long countDeliveries(@Param("vehicleId") long vehicleId, @Param("vehicleKey") String vehicleKey); + @Select("SELECT d.delivery_no,d.dispatch_no,t.trip_no,d.bol_no,d.customer_name,d.destination_type," + + "d.destination_warehouse_code,d.destination_warehouse_name,d.delivery_address,d.planned_delivery_at,d.status," + + "CASE WHEN " + DEPARTED + " THEN 'DEPARTED' ELSE 'PLANNED' END AS relationship" + DELIVERIES + + " ORDER BY d.created_at DESC,d.id DESC LIMIT #{size} OFFSET #{offset}") + List deliveries(@Param("vehicleId") long vehicleId, @Param("vehicleKey") String vehicleKey, + @Param("offset") long offset, @Param("size") int size); + @Select("SELECT COUNT(*)" + CHANGES) + long countChanges(@Param("vehicleKey") String vehicleKey); + @Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,h.snapshot_json AS after_snapshot" + + CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}") + List changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java new file mode 100644 index 000000000..255e135c8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.trip; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +public final class VehicleFulfillmentModels { + private VehicleFulfillmentModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record Delivery(String deliveryNo, String dispatchNo, String tripNo, String bolNo, + String customerName, String destinationType, String destinationWarehouseCode, + String destinationWarehouseName, String deliveryAddress, + LocalDateTime plannedDeliveryAt, String status, String relationship) { } + public record ChangeRow(String tripNo, long recordVersion, LocalDateTime occurredAt, String reason, + String beforeSnapshot, String afterSnapshot) { } + public record Change(String tripNo, long recordVersion, Instant occurredAt, String reason, + String previousVehiclePlate, String vehiclePlate, + String previousDriverName, String driverName, String relationship, + List dispatchNos) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java new file mode 100644 index 000000000..2c8337b0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.ZoneOffset; +import java.util.ArrayList; + +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class VehicleFulfillmentService { + private final VehicleRepository vehicles; + private final VehicleFulfillmentMapper mapper; + private final ObjectMapper json; + public VehicleFulfillmentService(VehicleRepository vehicles, VehicleFulfillmentMapper mapper, ObjectMapper json) { + this.vehicles = vehicles; this.mapper = mapper; this.json = json; + } + public PageResult deliveries(long vehicleId, VehicleFulfillmentModels.Query query) { + requireVehicle(vehicleId); String key = Long.toString(vehicleId); + long total = mapper.countDeliveries(vehicleId, key); + return new PageResult<>(mapper.deliveries(vehicleId, key, query.offset(), query.size()), query.page(), query.size(), total, pages(total, query.size())); + } + public PageResult changes(long vehicleId, VehicleFulfillmentModels.Query query) { + requireVehicle(vehicleId); String key = Long.toString(vehicleId); + long total = mapper.countChanges(key); + var records = mapper.changes(key, query.offset(), query.size()).stream().map(row -> change(key, row)).toList(); + return new PageResult<>(records, query.page(), query.size(), total, pages(total, query.size())); + } + private VehicleFulfillmentModels.Change change(String key, VehicleFulfillmentModels.ChangeRow row) { + try { + JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip"); + if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照"); + var dispatches = new ArrayList(); + for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText()); + String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED" + : key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN"; + return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(), + text(before, "vehiclePlate"), text(after, "vehiclePlate"), text(before, "driverName"), text(after, "driverName"), relationship, java.util.List.copyOf(dispatches)); + } catch (JsonProcessingException error) { throw new IllegalStateException("车辆更换历史快照无法读取", error); } + } + private static String text(JsonNode value, String key) { return value.hasNonNull(key) ? value.get(key).asText() : null; } + private static long pages(long total, int size) { return total / size + (total % size == 0 ? 0 : 1); } + private void requireVehicle(long vehicleId) { + vehicles.findById(vehicleId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆不存在")); + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml new file mode 100644 index 000000000..2387545f1 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml @@ -0,0 +1,105 @@ +openapi: 3.0.3 +info: + title: 车辆履约与更换历史 + version: 1.0.0 + description: >- + 平台按稳定车辆ID读取Lastleg本库履约事实,不按当前车牌文本反查,不修改单据或历史。 + 计划中的派送归当前分配车辆;实际发车后按不可变DEPART历史中的车辆身份归属。 + 关联排车和派送时同时匹配客户归属,不返回财务、附件、联系人或内部数据库主键。 + 更换记录依据相邻车次版本的ASSIGN快照,首次分配和资源未变化的操作不算更换。 + 旧车和新车均能读取同一更换事实;仅换司机也保留。返回历史车牌/姓名,不用当前主档覆盖。 + 不生成原型示例记录;没有正式车次关联的旧派送不通过车牌猜测归属。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/deliveries: + parameters: + - {$ref: '#/components/parameters/Vehicle'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + get: + summary: 当前车辆关联派送记录 + description: '同时要求 platform:overseas-master-data:vehicle:list 与 platform:overseas-warehouse:delivery:list。按派送创建时间、内部排序键倒序分页;排序键不返回。' + responses: + '200': + description: '统一ApiResponse,成功code为200,data为分页;车辆不存在返回业务404语义。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + allOf: + - {$ref: '#/components/schemas/Page'} + - type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Delivery'}} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/assignment-history: + parameters: + - {$ref: '#/components/parameters/Vehicle'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + get: + summary: 当前车辆相关的换车与换司机事实 + description: '同时要求 platform:overseas-master-data:vehicle:list 与 platform:fleet:trip:list。按事件时间、历史排序键倒序分页,一行一个变更事件;关联排车使用该事件快照,不使用当前关联覆盖历史。' + responses: + '200': + description: '统一ApiResponse,data为分页。历史记录只含稳定业务编号及更换事实,不公开整份客户快照或操作人内部ID。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + allOf: + - {$ref: '#/components/schemas/Page'} + - type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Change'}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + Vehicle: {name: vehicleId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 10}} + schemas: + Page: + type: object + required: [records, page, size, total, pages] + properties: + page: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 100} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + Delivery: + type: object + required: [deliveryNo, dispatchNo, tripNo, status, relationship] + properties: + deliveryNo: {type: string} + dispatchNo: {type: string} + tripNo: {type: string} + bolNo: {type: string, nullable: true} + customerName: {type: string, nullable: true} + destinationType: {type: string} + destinationWarehouseCode: {type: string, nullable: true} + destinationWarehouseName: {type: string, nullable: true} + deliveryAddress: {type: string, nullable: true} + plannedDeliveryAt: {type: string, nullable: true, description: 服务端作业时区的本地日期时间} + status: {type: string} + relationship: {type: string, enum: [PLANNED, DEPARTED]} + Change: + type: object + required: [tripNo, recordVersion, occurredAt, relationship, dispatchNos] + properties: + tripNo: {type: string} + recordVersion: {type: string, pattern: '^[0-9]+$', description: 车次版本字符串,与车次号组成事件标识} + occurredAt: {type: string, format: date-time} + reason: {type: string, nullable: true} + previousVehiclePlate: {type: string, nullable: true} + vehiclePlate: {type: string, nullable: true} + previousDriverName: {type: string, nullable: true} + driverName: {type: string, nullable: true} + relationship: {type: string, enum: [REPLACED_OUT, REPLACED_IN, DRIVER_CHANGED]} + dispatchNos: {type: array, items: {type: string}} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentContractTest.java new file mode 100644 index 000000000..7aef5e5ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.security.access.prepost.PreAuthorize; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleFulfillmentContractTest { + private static final String VEHICLE="platform:overseas-master-data:vehicle:list", DELIVERY="platform:overseas-warehouse:delivery:list", TRIP="platform:fleet:trip:list"; + private void login(String... authorities) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused", + Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList())); + } + @Test void vehicleViewingDoesNotGrantDeliveryOrTripDataAndEachConsumerNeedsItsOwnReadPermission() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformVehicleFulfillmentController.class); + var service=context.getBean(VehicleFulfillmentService.class); + var query=new VehicleFulfillmentModels.Query(1,10); + for(String[] granted:List.of(new String[]{VEHICLE},new String[]{DELIVERY,TRIP},new String[]{})) { + login(granted); + assertThatThrownBy(()->controller.deliveries(8,query)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.changes(8,query)).isInstanceOf(AccessDeniedException.class); + } + verifyNoInteractions(service); + login(VEHICLE,DELIVERY); controller.deliveries(8,query); + assertThatThrownBy(()->controller.changes(8,query)).isInstanceOf(AccessDeniedException.class); + login(VEHICLE,TRIP); controller.changes(8,query); + assertThatThrownBy(()->controller.deliveries(8,query)).isInstanceOf(AccessDeniedException.class); + verify(service).deliveries(8,query); verify(service).changes(8,query); verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Test void publicGetOperationsAndResponseFieldsAreDocumentedAndQueriesAreReadOnly() throws Exception { + Map document=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml"))); + Map paths=(Map)document.get("paths"); + String base=PlatformVehicleFulfillmentController.class.getAnnotation(RequestMapping.class).value()[0]; + for(var method:PlatformVehicleFulfillmentController.class.getDeclaredMethods()) if(method.isAnnotationPresent(GetMapping.class)) { + String path=base+method.getAnnotation(GetMapping.class).value()[0]; + Map get=(Map)((Map)paths.get(path)).get("get"); + String expression=method.getAnnotation(PreAuthorize.class).value(); + for(String permission:List.of(VEHICLE,DELIVERY,TRIP)) if(expression.contains(permission)) assertThat(get.get("description").toString()).contains(permission); + } + Map schemas=(Map)((Map)document.get("components")).get("schemas"); + for(var type:List.of(VehicleFulfillmentModels.Delivery.class,VehicleFulfillmentModels.Change.class)) { + Map properties=(Map)((Map)schemas.get(type.getSimpleName())).get("properties"); + assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder(Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new)); + } + assertThat(VehicleFulfillmentService.class.getAnnotation(Transactional.class).readOnly()).isTrue(); + } + @Configuration @EnableMethodSecurity static class Config { + @Bean VehicleFulfillmentService service(){return mock(VehicleFulfillmentService.class);} + @Bean PlatformVehicleFulfillmentController controller(VehicleFulfillmentService service){return new PlatformVehicleFulfillmentController(service);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java new file mode 100644 index 000000000..609f72457 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */ +public class VehicleFulfillmentQueryTest { + private JdbcTemplate jdbc; + private VehicleFulfillmentMapper mapper; + private VehicleFulfillmentService service; + private VehicleRepository vehicles; + private static final ObjectMapper JSON = new ObjectMapper(); + public static String jsonExtract(String source, String path) throws Exception { + if (source == null) return null; + var value = JSON.readTree(source); + for (String field : path.substring(2).split("\\.")) value = value.path(field); + return value.isMissingNode() ? null : value.toString(); + } + public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); } + @BeforeEach void setup() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'"); + jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'"); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))"); + jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)"); + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); + mapper = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())).getMapper(VehicleFulfillmentMapper.class); + vehicles = mock(VehicleRepository.class); + when(vehicles.findById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class))); + service = new VehicleFulfillmentService(vehicles, mapper, JSON); + jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-1',8,'PLANNED')"); + jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1,'CUSTOMER-A'),(12,1,'CUSTOMER-B')"); + delivery(21,11,"CUSTOMER-A"); delivery(22,12,"CUSTOMER-B"); + } + private void delivery(long deliveryId, long dispatchId, String customer) { + jdbc.update("INSERT INTO ll_delivery_order(id,delivery_no,dispatch_no,dispatch_order_id,customer_id,customer_name,destination_type,destination_warehouse_code,status,created_at) VALUES(?,?,?,?,?,?,'FBA','ONT8','WAIT_DELIVERY',TIMESTAMP '2026-10-03 00:00:00')", + deliveryId,"DL-"+deliveryId,"DP-"+dispatchId,dispatchId,customer,customer); + } + private void history(long version, String action, String vehicle, String driver) throws Exception { + var trip = new HashMap(); + if (vehicle != null) { trip.put("vehicleId",vehicle); trip.put("vehiclePlate","PLATE-"+vehicle); } + if (driver != null) { trip.put("driverId",driver); trip.put("driverName","司机"+driver); } + String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"),Map.of("dispatchNo","DP-12")))); + jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(?,1,?,TIMESTAMP '2026-10-03 00:00:00',?,'更换车辆',?)",version+1,version,action,snapshot); + } + @Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() { + delivery(23,11,"WRONG-CUSTOMER"); + var result = service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)); + assertThat(result.total()).isEqualTo(2); + assertThat(result.records()).extracting(VehicleFulfillmentModels.Delivery::customerName).containsExactly("CUSTOMER-B","CUSTOMER-A"); + assertThat(result.records()).allMatch(row -> row.relationship().equals("PLANNED")); + assertThat(service.deliveries(7,new VehicleFulfillmentModels.Query(1,10)).records()).isEmpty(); + } + @Test void departedVehicleIsReadFromImmutableSnapshotAfterCurrentAssignmentChanges() throws Exception { + history(0,"DEPART","8","18"); history(1,"COMPLETE","8","18"); + jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='COMPLETED'"); + assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,1)).records()).singleElement().satisfies(row -> { + assertThat(row.deliveryNo()).isEqualTo("DL-22"); assertThat(row.relationship()).isEqualTo("DEPARTED"); + }); + assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(2,1)).records()).extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-21"); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); + assertThat(service.deliveries(18,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); + } + @Test void cancelledUnexecutedTripIsNotPresentedAsActualTransport() { + jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'"); + assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(null,null)).total()).isZero(); + } + @Test void initialAssignmentIsNotAReplacementAndBothOldAndNewVehiclesRetainReplacement() throws Exception { + history(0,"CREATE",null,null); history(1,"ASSIGN","7","17"); history(2,"ASSIGN","8","18"); + var out = service.changes(7,new VehicleFulfillmentModels.Query(1,10)); + var in = service.changes(8,new VehicleFulfillmentModels.Query(1,10)); + assertThat(out.total()).isEqualTo(1); assertThat(in.total()).isEqualTo(1); + assertThat(out.records().get(0).relationship()).isEqualTo("REPLACED_OUT"); + assertThat(in.records().get(0).relationship()).isEqualTo("REPLACED_IN"); + assertThat(out.records().get(0).previousVehiclePlate()).isEqualTo("PLATE-7"); + assertThat(out.records().get(0).vehiclePlate()).isEqualTo("PLATE-8"); + assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11","DP-12"); + assertThat(out.records().get(0).occurredAt().toString()).isEqualTo("2026-10-03T00:00:00Z"); + assertThat(service.changes(77,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); + } + @Test void driverOnlyReplacementIsVisibleForSameVehicleAndNoOpIsExcluded() throws Exception { + history(0,"ASSIGN","8","17"); history(1,"ASSIGN","8","18"); history(2,"ASSIGN","8","18"); + var result=service.changes(8,new VehicleFulfillmentModels.Query(1,10)); + assertThat(result.total()).isEqualTo(1); assertThat(result.records().get(0).relationship()).isEqualTo("DRIVER_CHANGED"); + } + @Test void nonexistentVehicleCannotBeUsedToQueryOrLeakHistory() { + when(vehicles.findById(404L)).thenReturn(Optional.empty()); + assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在"); + assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在"); + } +} -- 2.54.0 From d4d5d7907033d7b21edcc64ba815ddb1ecddff25 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 10:18:48 +0800 Subject: [PATCH 089/176] fix(fleet): compare customer identities without collation conflicts --- .../com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java | 2 +- .../baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java index c86fa011e..3e6b5616c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -11,7 +11,7 @@ public interface VehicleFulfillmentMapper { String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})"; String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " - + "ON b.dispatch_id=d.dispatch_order_id AND b.customer_id=d.customer_id " + + "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) " + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))"; String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java index 609f72457..08773e1f1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java @@ -59,6 +59,8 @@ public class VehicleFulfillmentQueryTest { } @Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() { delivery(23,11,"WRONG-CUSTOMER"); + delivery(24,11,"CUSTOMER-A-OTHER"); + delivery(25,11,"customer-a"); var result = service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)); assertThat(result.total()).isEqualTo(2); assertThat(result.records()).extracting(VehicleFulfillmentModels.Delivery::customerName).containsExactly("CUSTOMER-B","CUSTOMER-A"); -- 2.54.0 From ba7dcd39f24ff133039e3635a1707d4a9789922a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 10:54:03 +0800 Subject: [PATCH 090/176] test(fleet): verify vehicle care lifecycle independently --- scripts/admin-test-env-acceptance.mjs | 2 +- scripts/admin-test-env-modules/README.md | 2 + .../fleet-vehicle-care.mjs | 52 +++++++++++++++++++ ...se4-overseas-master-data-contract.test.mjs | 37 +++++++++++++ .../admin-test-env-modules/vehicle-care.mjs | 16 +++--- 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 scripts/admin-test-env-modules/fleet-vehicle-care.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1cce9dd2c..bfa5d390e 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4d610f410..4d8ddada4 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +`fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 + `fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 `fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁证据与事务中途故障回滚严格区分,后者保持 `NOT_TESTED`。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 diff --git a/scripts/admin-test-env-modules/fleet-vehicle-care.mjs b/scripts/admin-test-env-modules/fleet-vehicle-care.mjs new file mode 100644 index 000000000..2b13f590c --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-vehicle-care.mjs @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto' +import { verifyVehicleCare } from './vehicle-care.mjs' + +const MODULE = 'fleet-vehicle-care' +const BASE = '/api/platform/v1/last-leg/master-data/vehicles' +const OPTIONS = { scenario: 'vehicle-care-lifecycle', deploymentBlocker: true } + +/** Fresh TEST fixtures only. No dispatch, inventory or financial writes. */ +export async function run(context) { + const proof = { status: 'CHECKING', care: {}, cleanup: 'PENDING' } + context.createdResources.fleetVehicleCare = proof + async function command(method, path, body, label) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || data == null) throw new Error(label) + return data + } + let failure + try { + const vehicle = await command('POST', BASE, { + plateNumber: `VC-${randomUUID().slice(0, 12)}`, model: 'Vehicle care TEST', + fuelConsumptionPer100Km: 10, mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', + status: 'IN_SERVICE', remark: context.marker, + }, '建立本轮车辆运维专用档案') + proof.vehicleId = String(vehicle.id) + proof.plateNumber = vehicle.plateNumber + await verifyVehicleCare(context, MODULE, vehicle, proof.care) + proof.status = 'VERIFIED' + } catch (error) { + failure = error + proof.status = 'FAILED' + proof.error = error instanceof Error ? error.message : String(error) + } finally { + if (proof.vehicleId) { + try { + const path = `${BASE}/${encodeURIComponent(proof.vehicleId)}` + const current = await command('GET', path, undefined, '结束前读取本轮车辆当前版本') + const ended = current.status === 'INACTIVE' ? current : await command('PATCH', `${path}/status`, { + recordVersion: current.recordVersion, status: 'INACTIVE', + }, '停用本轮运维专用车辆并保留证据') + if (ended.status !== 'INACTIVE') throw new Error('本轮运维车辆未停用') + proof.cleanup = 'INACTIVE' + } catch (error) { + proof.cleanup = 'FAILED' + proof.cleanupError = error instanceof Error ? error.message : String(error) + proof.status = 'FAILED' + failure ??= error + } + } + } + if (failure) throw failure +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 8d2840a89..782c279e6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -2,6 +2,43 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs' +import { run as runCare } from './fleet-vehicle-care.mjs' + +test('运维专项接受NON_NULL真实响应但仍完成全部六类记录和两车停用', async () => { + const { context, state } = fakeContext() + const request = context.request.bind(context) + context.request = async (...args) => JSON.parse(JSON.stringify(await request(...args), (_key, value) => value === null ? undefined : value)) + await runCare(context) + assert.equal(context.createdResources.fleetVehicleCare.status, 'VERIFIED') + assert.equal(context.createdResources.fleetVehicleCare.care.records.length, 6) + assert.equal(context.createdResources.fleetVehicleCare.cleanup, 'INACTIVE') + assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(state.spareVehicle.status, 'INACTIVE') +}) + +test('省略实际维修项目仍失败,失败后停用主车辆并保留报告', async () => { + const { context, state } = fakeContext({ softAssertions: true }) + const request = context.request.bind(context) + context.request = async (...args) => { + const response = await request(...args) + if (response.body?.data?.details?.recordType === 'REPAIR') delete response.body.data.details.incident.item + return response + } + await assert.rejects(runCare(context), /事件项目地点和分类完整回显/) + assert.equal(context.createdResources.fleetVehicleCare.status, 'FAILED') + assert.equal(context.createdResources.fleetVehicleCare.cleanup, 'INACTIVE') + assert.equal(state.vehicle.status, 'INACTIVE') +}) + +test('运维专项停用失败不得标记整个运行通过', async () => { + const { context } = fakeContext() + const request = context.request.bind(context) + context.request = async (...args) => args[0] === 'PATCH' && args[1].endsWith('/930000000000000001/status') + ? { http: 503, body: { code: 'S0503' } } : request(...args) + await assert.rejects(runCare(context)) + assert.equal(context.createdResources.fleetVehicleCare.status, 'FAILED') + assert.equal(context.createdResources.fleetVehicleCare.cleanup, 'FAILED') +}) test('vehicle capacity evidence rejects absent, zero and guessed dimensions', () => { const expected = { maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } diff --git a/scripts/admin-test-env-modules/vehicle-care.mjs b/scripts/admin-test-env-modules/vehicle-care.mjs index fb336e83a..2e45f666a 100644 --- a/scripts/admin-test-env-modules/vehicle-care.mjs +++ b/scripts/admin-test-env-modules/vehicle-care.mjs @@ -56,19 +56,23 @@ export async function verifyVehicleCare(context, module, vehicle, proof) { fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker, incident: { description: `${context.marker} 受控故障处置记录`, item: priced ? '更换轮胎' : null, place: priced ? '本轮测试作业点' : null, handler: null, processingStatus: 'PENDING', exceptionType: type === 'EXCEPTION' ? 'VEHICLE_BREAKDOWN' : null, - reportedCost: priced ? 0 : null, currency: priced ? 'USD' : null } } + reportedCost: type === 'REPAIR' ? 125.5 : priced ? 0 : null, currency: priced ? 'USD' : null } } const initial = { idempotencyKey: `${context.marker}:${type}`, details } let incident = await command('POST', base, initial, `创建本轮${type}记录`) proof.records.push({ recordNo: incident.recordNo, createRequest: initial }) - check('事件项目地点和分类完整回显', null, incident.vehicleId === id && incident.details?.incident?.item === details.incident.item - && incident.details.incident.place === details.incident.place && incident.details.incident.exceptionType === details.incident.exceptionType) + check('事件项目地点和分类完整回显', null, incident.vehicleId === id && (incident.details?.incident?.item ?? null) === details.incident.item + && (incident.details.incident.place ?? null) === details.incident.place && (incident.details.incident.exceptionType ?? null) === details.incident.exceptionType) incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成', details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`) check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED' - && (!priced || (Number(incident.details.incident.reportedCost) === 0 && incident.paymentStatus === 'UNLINKED'))) + && (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost && incident.paymentStatus === 'UNLINKED'))) const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`) - check('处理记录重新读取保持全部现场字段', null, refreshed.details.incident.item === details.incident.item - && refreshed.details.incident.place === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') + check('处理记录重新读取保持全部现场字段', null, (refreshed.details.incident.item ?? null) === details.incident.item + && (refreshed.details.incident.place ?? null) === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') + const changes = await command('GET', `${base}/${incident.recordNo}/history?page=1&size=20`, undefined, `核对本轮${type}处理历史`) + check('处理历史保留原待处理事实及完成结果', null, changes.records?.length === 2 + && changes.records[0].snapshot?.details?.incident?.processingStatus === 'COMPLETED' + && changes.records[1].snapshot?.details?.incident?.processingStatus === 'PENDING') } let other try { -- 2.54.0 From 65c12eaf5e5c6a8397435025bc91cec64df58a18 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 11:09:52 +0800 Subject: [PATCH 091/176] fix: expose the selected container schedule for editing --- .../entity/ContainerBookingEntity.java | 4 ++ .../repository/ContainerRepository.java | 5 ++- .../main/resources/openapi/firstleg-v1.yaml | 1 + .../ContainerRepositoryLoadingTimeTest.java | 38 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) 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 1901cacff..fd19f6105 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.entity; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; @@ -75,6 +76,9 @@ public class ContainerBookingEntity { @TableField(exist = false) private Long planId; @TableField(exist = false) private String planNo; @TableField(exist = false) private String planStatus; + /** Identity of the port schedule selected for the current transport read model. */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @TableField(exist = false) private Long currentPortScheduleId; /** Actual completion timestamp projected from the associated loading plan. */ @JsonIgnore @TableField(exist = false) private LocalDateTime completedLoadingAt; @TableField(exist = false) private Boolean exceptionActive; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 4d9266c9c..b4b318752 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -71,9 +71,12 @@ public class ContainerRepository { } private void resolveCurrentPortSchedule(ContainerBookingEntity value) { - if (value == null || value.getCustomerId() == null || value.getOceanBookingId() == null) return; + if (value == null) return; + value.setCurrentPortScheduleId(null); + if (value.getCustomerId() == null || value.getOceanBookingId() == null) return; PortScheduleEntity schedule = schedules.latestForDispatch(value.getCustomerId(), value.getOceanBookingId(), value.getId()); if (schedule == null) return; + value.setCurrentPortScheduleId(schedule.getId()); value.setVesselName(schedule.getVesselName()); value.setVoyageNo(schedule.getVoyageNo()); value.setEstimatedSailingAt(schedule.getEtdAt()); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index d70822b29..faa977f34 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -2260,6 +2260,7 @@ components: required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version] properties: id: {type: string, pattern: '^[1-9][0-9]*$'} + currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java index 2cd56dd02..f80e3b4f3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java @@ -4,6 +4,8 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper; +import com.baomidou.mybatisplus.annotation.TableField; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -32,6 +34,7 @@ class ContainerRepositoryLoadingTimeTest { ContainerBookingEntity result = repository.find(7L); assertThat(result.getLoadingAt()).isEqualTo(completedAt); + assertThat(result.getCurrentPortScheduleId()).isNull(); } @Test @@ -58,6 +61,7 @@ class ContainerRepositoryLoadingTimeTest { booking.setVesselName("OLD-VESSEL"); booking.setVoyageNo("OLD-V01"); PortScheduleEntity schedule = new PortScheduleEntity(); + schedule.setId(11L); schedule.setVesselName("NEW-VESSEL"); schedule.setVoyageNo("NEW-V01"); schedule.setEtdAt(LocalDateTime.of(2026, 10, 7, 8, 0)); @@ -68,9 +72,43 @@ class ContainerRepositoryLoadingTimeTest { ContainerBookingEntity result = repository.find(8L); assertThat(result.getContainerNo()).isEqualTo("TSTU0000001"); + assertThat(result.getCurrentPortScheduleId()).isEqualTo(11L); assertThat(result.getVesselName()).isEqualTo("NEW-VESSEL"); assertThat(result.getVoyageNo()).isEqualTo("NEW-V01"); assertThat(result.getEstimatedSailingAt()).isEqualTo(schedule.getEtdAt()); assertThat(result.getEstimatedPortArrivalAt()).isEqualTo(schedule.getEtaAt()); } + + @Test + void leavesCurrentPortScheduleNullWhenNoScheduleMatches() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(8L); + booking.setCustomerId(9L); + booking.setOceanBookingId(10L); + booking.setCurrentPortScheduleId(11L); + booking.setVesselName("BOOKING-VESSEL"); + booking.setVoyageNo("BOOKING-V01"); + when(mapper.selectBooking(8L)).thenReturn(booking); + when(schedules.latestForDispatch(9L, 10L, 8L)).thenReturn(null); + + ContainerBookingEntity result = repository.find(8L); + + assertThat(result.getCurrentPortScheduleId()).isNull(); + assertThat(result.getVesselName()).isEqualTo("BOOKING-VESSEL"); + assertThat(result.getVoyageNo()).isEqualTo("BOOKING-V01"); + } + + @Test + void currentPortScheduleIsNonPersistentAndSerializedReadOnly() throws Exception { + var field = ContainerBookingEntity.class.getDeclaredField("currentPortScheduleId"); + assertThat(field.getAnnotation(TableField.class).exist()).isFalse(); + ObjectMapper json = new ObjectMapper(); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setCurrentPortScheduleId(11L); + + assertThat(json.readTree(json.writeValueAsString(booking)).get("currentPortScheduleId").asLong()) + .isEqualTo(11L); + assertThat(json.readValue("{\"currentPortScheduleId\":11}", ContainerBookingEntity.class) + .getCurrentPortScheduleId()).isNull(); + } } -- 2.54.0 From 34cac9c42a56f30deb2942894838e87e6b639862 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 11:53:58 +0800 Subject: [PATCH 092/176] test(fleet): verify driver qualification lifecycle --- scripts/admin-test-env-acceptance.mjs | 2 +- scripts/admin-test-env-modules/README.md | 2 + ...eet-driver-qualification-contract.test.mjs | 58 +++++++++ .../fleet-driver-qualification.mjs | 115 ++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-driver-qualification.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index bfa5d390e..9d150880c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4d8ddada4..0c865f61d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +`fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。 + `fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 `fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 diff --git a/scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs b/scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs new file mode 100644 index 000000000..63bd544b1 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { run } from './fleet-driver-qualification.mjs' +const copy = value => structuredClone(value) +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') +function fixture(fault) { + const rows = new Map(), history = new Map(), calls = [], files = new Map() + const response = (data, code='200', http=200) => ({ http, body: { code, data: copy(data) }, requestId: 'test-request', traceId: 'test-trace' }) + function qualification(row, expectedEnd) { + const now = Date.now(), expiry = Date.parse(row.details.licenseExpiresOn + 'T00:00:00Z') + const expiringSoon = expiry >= now - 86400000 && expiry - now <= 30 * 86400000 + let reason = !row.active ? 'INACTIVE' : !row.licenseFileId ? 'MISSING_LICENSE' : row.reviewStatus === 'REJECTED' ? 'REJECTED' + : row.reviewStatus !== 'APPROVED' ? 'PENDING_REVIEW' : !expectedEnd ? 'MISSING_TRIP_END' + : Date.parse(expectedEnd) <= now ? 'INVALID_TRIP_END' : Date.parse(expectedEnd) >= expiry + 86400000 ? 'EXPIRES_DURING_TRIP' : 'ELIGIBLE' + if (fault === 'allows expiry boundary' && reason === 'EXPIRES_DURING_TRIP') reason = 'ELIGIBLE' + return { canDispatch: reason === 'ELIGIBLE', reason, expiringSoon: fault === 'missing warning' ? false : expiringSoon } + } + function present(row) { return { ...copy(row), qualification: qualification(row), licenseState: row.details.licenseExpiresOn < new Date().toISOString().slice(0,10) ? 'EXPIRED' : 'EXPIRING' } } + function record(row, action) { history.get(row.id).unshift({ action, recordVersion: row.recordVersion, snapshot: present(row) }) } + const context = { marker: 'CONTROLLED-DRIVER-TEST', createdResources: {}, + assert(_module, label, _response, condition) { assert.ok(condition, label) }, + apiSuccess(_module, label, value) { assert.equal(value.http, 200, label); assert.equal(value.body?.code, '200', label); return value.body.data }, + async request(method, raw, body) { + calls.push({ method, raw, body: copy(body) }); const url = new URL(raw, 'https://fixture.invalid'), parts = url.pathname.split('/drivers'), suffix = parts[1] + if (method === 'POST' && suffix === '') { const id = String(rows.size+1); const row = { id, driverNo: 'DR-'+id, details: copy(body), active: true, reviewStatus: 'PENDING', licenseFileId: null, recordVersion: 0 }; rows.set(id,row); history.set(id,[]); record(row,'CREATE'); return response(present(row)) } + const [, id, action, fileId] = suffix.split('/'), row = rows.get(id); assert.ok(row) + if (action === 'license') return fault === 'file outage' ? response(null, 'S0503', 503) : response(null, fault === 'cross-driver leak' ? '200' : 'A0404') + if (method === 'GET' && !action) return response(present(row)) + if (action === 'qualification') return response(qualification(row, url.searchParams.get('expectedEnd'))) + if (action === 'history') return response({ records: history.get(id) }) + if (Number(body.recordVersion) !== row.recordVersion) return response(null, 'A0409') + if (action === 'review') { + if (body.decision === 'APPROVED' && (!row.licenseFileId || row.details.licenseExpiresOn < new Date().toISOString().slice(0,10))) return response(null, 'A0400') + if (body.decision === 'REJECTED' && !body.reason) return response(null, 'A0400') + row.reviewStatus = body.decision + } else if (action === 'submit') row.reviewStatus = 'PENDING' + else if (action === 'status') row.active = body.active + else if (method === 'PUT') { if (body.details.licenseExpiresOn !== row.details.licenseExpiresOn && fault !== 'renewal skips review') row.reviewStatus='PENDING'; row.details=copy(body.details) } + else throw new Error(`Unexpected request ${method} ${raw}`) + row.recordVersion++; record(row, action === 'review' ? body.decision : action === 'submit' ? 'RESUBMIT' : action === 'status' ? 'DEACTIVATE' : 'UPDATE'); return response(present(row)) + }, + async requestMultipart(method, raw) { calls.push({ method, raw }); const id = raw.split('/drivers/')[1].split('/')[0], row = rows.get(id); row.licenseFileId=String(files.size+1);files.set(row.licenseFileId,id);row.reviewStatus='PENDING';row.recordVersion++;record(row,'UPLOAD_LICENSE');return response(present(row)) }, + async requestBinary(method, raw) { calls.push({ method, raw });return { http: 200, contentType: 'image/png', bytes: fault === 'corrupt image' ? Buffer.from('wrong') : PNG } }, + } + return { context, rows, calls } +} +test('full driver fixture checks lifecycle and deactivates both owned drivers', async () => { + const { context, rows } = fixture(); await run(context) + assert.equal(context.createdResources.fleetDriverQualification.status, 'VERIFIED') + assert.equal(rows.size, 2); assert.ok([...rows.values()].every(row=>!row.active)) +}) +for (const fault of ['allows expiry boundary', 'missing warning', 'cross-driver leak', 'file outage', 'renewal skips review', 'corrupt image']) { + test(`driver verifier rejects ${fault} and still deactivates created drivers`, async () => { + const { context, rows } = fixture(fault); await assert.rejects(run(context)) + assert.equal(context.createdResources.fleetDriverQualification.status, 'FAILED') + assert.ok(rows.size>0); assert.ok([...rows.values()].every(row=>!row.active)) + }) +} diff --git a/scripts/admin-test-env-modules/fleet-driver-qualification.mjs b/scripts/admin-test-env-modules/fleet-driver-qualification.mjs new file mode 100644 index 000000000..dfdc32a86 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-driver-qualification.mjs @@ -0,0 +1,115 @@ +import { randomUUID } from 'node:crypto' + +const MODULE = 'fleet-driver-qualification' +const BASE = '/api/platform/v1/last-leg/fleet/drivers' +const OPTIONS = { scenario: 'driver-license-and-qualification', deploymentBlocker: true } +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +/** Uses only new controlled TEST drivers, never real licence documents or existing driver assignments. */ +export async function run(context) { + const proof = { status: 'CHECKING', drivers: [], requests: [], qualifications: [] } + context.createdResources.fleetDriverQualification = proof + const marker = `DQ-${randomUUID().slice(0, 12)}` + const day = offset => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10) + const expiry = day(15), nextDay = new Date(Date.parse(`${expiry}T00:00:00Z`) + 86400000).toISOString() + const endBefore = new Date(Date.parse(nextDay) - 1).toISOString() + const soonEnd = new Date(Date.now() + 86400000).toISOString() + const details = { name: `${marker}受控司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: expiry, + remark: `${context.marker} TEST image, not a real driving licence`, + profile: { vehicleModels: 'Box Truck', languagesAndArea: 'TEST', medicalExpiresOn: day(-1), trainingExpiresOn: day(-1), insuranceExpiresOn: day(-1) } } + function check(label, response, condition) { + context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(label) + } + async function request(method, path, body) { + const response = await context.request(method, path, body) + proof.requests.push({ method, path, http: response.http, code: response.body?.code, requestId: response.requestId, traceId: response.traceId }) + return response + } + async function command(method, path, body, label) { + const response = await request(method, path, body) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + if (response.http !== 200 || String(response.body?.code) !== '200' || data == null) throw new Error(label) + return data + } + async function reject(method, path, body, code, label) { + const response = await request(method, path, body) + check(label, response, response.http === 200 && String(response.body?.code) === code) + } + async function qualification(id, end, reason, expiringSoon = undefined) { + const result = await command('GET', `${BASE}/${id}/qualification?expectedEnd=${encodeURIComponent(end)}`, undefined, `读取司机资格:${reason}`) + proof.qualifications.push({ driverId: String(id), expectedEnd: end, result }) + check(`司机资格准确判定${reason}`, null, result.reason === reason && result.canDispatch === (reason === 'ELIGIBLE') + && (expiringSoon === undefined || result.expiringSoon === expiringSoon)) + return result + } + async function download(id, fileId, label) { + const result = await context.requestBinary('GET', `${BASE}/${id}/license/${fileId}`) + check(label, result, result.http === 200 && result.contentType?.startsWith('image/png') && result.bytes instanceof Uint8Array && Buffer.from(result.bytes).equals(PNG)) + } + let failure + try { + let driver = await command('POST', BASE, details, '建立本轮临近到期司机') + const own = { id: String(driver.id), driverNo: driver.driverNo, initialDetails: details, files: [] } + proof.drivers.push(own) + const id = own.id, path = `${BASE}/${id}` + await qualification(id, soonEnd, 'MISSING_LICENSE') + await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, 'A0400', '缺驾驶证附件不能审核通过') + const uploaded = await context.requestMultipart('POST', `${path}/license?recordVersion=${driver.recordVersion}`, { name: `${marker}-TEST.png`, type: 'image/png', content: PNG }) + driver = context.apiSuccess(MODULE, '上传本轮受控驾驶证图片', uploaded, '', OPTIONS) + check('上传图片保留待审而非直接通过', uploaded, driver?.licenseFileId && driver.reviewStatus === 'PENDING') + const originalFile = String(driver.licenseFileId); own.files.push(originalFile) + await qualification(id, soonEnd, 'PENDING_REVIEW') + await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'REJECTED' }, 'A0400', '打回不能缺少原因') + driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'REJECTED', reason: '核对本轮测试资料' }, '正式打回司机资料') + await qualification(id, soonEnd, 'REJECTED') + driver = await command('POST', `${path}/submit`, { recordVersion: driver.recordVersion }, '正式重提司机资料') + const beforeApproval = driver.recordVersion + driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, '通过已补齐驾驶证的测试司机') + await qualification(id, soonEnd, 'ELIGIBLE', true) + await qualification(id, endBefore, 'ELIGIBLE', true) + await qualification(id, nextDay, 'EXPIRES_DURING_TRIP', true) + const missingEnd = await command('GET', path, undefined, '不填任务结束时间读取详情') + check('没有预计结束时间不默认为可派', null, !missingEnd.qualification.canDispatch && missingEnd.qualification.reason === 'MISSING_TRIP_END') + await qualification(id, new Date(Date.now() - 60000).toISOString(), 'INVALID_TRIP_END') + await reject('PUT', path, { recordVersion: beforeApproval, details }, 'A0409', '旧司机版本不能覆盖审核结果') + await download(id, originalFile, '本司机驾驶证下载字节一致') + const other = await command('POST', BASE, { ...details, name: `${marker}隔离司机`, licenseNumber: `${marker}-B` }, '建立附件归属隔离司机') + proof.drivers.push({ id: String(other.id), driverNo: other.driverNo, files: [] }) + await reject('GET', `${BASE}/${other.id}/license/${originalFile}`, undefined, 'A0404', '其他司机不能通过自身路径下载本司机证照') + driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: { ...details, remark: '仅补充备注' } }, '更正非资格信息') + check('备注更正不清除已通过的驾驶资格', null, driver.reviewStatus === 'APPROVED') + await qualification(id, soonEnd, 'ELIGIBLE', true) + const renewed = { ...details, licenseExpiresOn: day(90) } + driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: renewed }, '更新证照有效期') + check('关键证照变化回到待审', null, driver.reviewStatus === 'PENDING') + await qualification(id, soonEnd, 'PENDING_REVIEW', false) + driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, '复核新证照有效期') + await qualification(id, soonEnd, 'ELIGIBLE', false) + const replacement = await context.requestMultipart('POST', `${path}/license?recordVersion=${driver.recordVersion}`, { name: `${marker}-REPLACEMENT-TEST.png`, type: 'image/png', content: PNG }) + driver = context.apiSuccess(MODULE, '替换本轮测试证照', replacement, '', OPTIONS) + check('替换附件必须重新审核', replacement, driver?.reviewStatus === 'PENDING' && driver.licenseFileId) + own.files.push(String(driver.licenseFileId)) + await download(id, originalFile, '原证照仍可按本司机历史归属下载') + await download(id, driver.licenseFileId, '当前证照可下载') + driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: { ...details, licenseExpiresOn: day(-1) } }, '登记已过期测试证照') + check('详情明确显示驾驶证已过期', null, driver.licenseState === 'EXPIRED' && !driver.qualification.canDispatch) + await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, 'A0400', '过期驾驶证不能通过审核') + const history = await command('GET', `${path}/history?page=1&size=200`, undefined, '读取司机完整操作历史') + check('历史保留上传审核打回重提和旧证照', null, ['CREATE', 'UPLOAD_LICENSE', 'REJECTED', 'RESUBMIT', 'APPROVED', 'UPDATE'].every(action => history.records.some(row => row.action === action)) + && history.records.some(row => String(row.snapshot?.licenseFileId) === originalFile)) + proof.status = 'VERIFIED' + } catch (error) { proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error); failure = error } + finally { + for (const resource of proof.drivers) { + try { + const current = await command('GET', `${BASE}/${resource.id}`, undefined, '停用前读取本轮司机最新版本') + const ended = current.active ? await command('PATCH', `${BASE}/${resource.id}/status`, { recordVersion: current.recordVersion, active: false, reason: '本轮测试结束,停用专用司机并保留证据' }, '停用本轮测试司机') : current + check('测试司机已停用', null, ended.active === false); resource.cleanup = 'INACTIVE' + await qualification(resource.id, soonEnd, 'INACTIVE') + } catch (error) { resource.cleanup = 'FAILED'; proof.status = 'FAILED'; resource.cleanupError = error instanceof Error ? error.message : String(error); failure ??= error } + } + } + if (failure) throw failure +} -- 2.54.0 From eadb431f332c63d7993a333efa2055c3fbd013f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 12:13:34 +0800 Subject: [PATCH 093/176] fix(lastleg): read committed order command replays after lock waits --- .../service/LastlegOrderCommandService.java | 7 +++- ...ventoryHoldConcurrencyPersistenceTest.java | 26 ++++++++++++ .../LastlegOrderCommandServiceTest.java | 12 ++++++ scripts/admin-test-env-modules/README.md | 2 +- ...ase4-tail-order-controls-contract.test.mjs | 41 +++++++++++++++++++ .../phase4-tail-order-controls.mjs | 20 ++++++--- 6 files changed, 100 insertions(+), 8 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java index 8fc8c86f4..9afa2784c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @@ -55,7 +56,9 @@ public class LastlegOrderCommandService { @Resource private InventoryCommandService inventory; @Resource private ObjectMapper objectMapper; - @Transactional + // Ownership is read before waiting for the order lock. Under MySQL repeatable + // read that snapshot can hide the preceding writer's idempotency record. + @Transactional(isolation = Isolation.READ_COMMITTED) public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId, String idempotencyKey, LastlegOrderCommandDtos.HoldRequest request, @@ -122,7 +125,7 @@ public class LastlegOrderCommandService { return response; } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId, String idempotencyKey, LastlegOrderCommandDtos.ManualNodesRequest request, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java index 217dbe046..4da37378b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -137,6 +137,32 @@ class InventoryHoldConcurrencyPersistenceTest { assertEquals(12,amount("available_packages")+amount("hold_packages")+amount("allocated_packages")+amount("picked_packages")+shipped); assertEquals(shipped,jdbc.queryForObject("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger WHERE business_type='SHIP'",Long.class)); } + @Test void simultaneousDuplicateHoldAndReleaseAdvanceOnceAndKeepIndependentHold() throws Exception { + inventory.hold("C1","operator",move(22,2,"OTHER-HOLD")); + ExecutorService pool=Executors.newFixedThreadPool(2); + try { + for (boolean held : new boolean[]{true,false}) { + int version=held?0:1; + String key=held?"parallel-hold":"parallel-release"; + var ready=new CountDownLatch(2);var start=new CountDownLatch(1); + Callable command=()->{ + ready.countDown();await(start); + return orders.hold("C1",11,key,new LastlegOrderCommandDtos.HoldRequest(held,false,"quality",version),"operator","Operator"); + }; + var first=pool.submit(command);var second=pool.submit(command); + await(ready);start.countDown(); + var response=first.get(8,TimeUnit.SECONDS); + assertEquals(response,second.get(8,TimeUnit.SECONDS)); + assertEquals(version+1,response.version()); + assertEquals(version+1,orderMapper.selectById(11L).getVersion()); + } + assertEquals(2,count("ll_order_command_record")); + assertEquals(2,count("ll_order_hold_history")); + assertEquals(5,count("ll_inventory_operation")); + assertEquals(5,count("ll_inventory_ledger")); + assertEquals(10,amount("available_packages"));assertEquals(2,amount("hold_packages"));conserved(0); + } finally {pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));} + } @Test void secondHoldCycleMustMoveInventoryAgainAndNotReuseTheFirstCycle() { hold(true,0,"hold-one"); hold(false,1,"release-one"); hold(true,2,"hold-two"); assertEquals(0,amount("available_packages")); assertEquals(12,amount("hold_packages")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java index 456183890..c917a4b98 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java @@ -66,6 +66,18 @@ class LastlegOrderCommandServiceTest { ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); } + @Test + void commandTransactionsReadCommittedAfterWaitingForTheOrderLock() throws Exception { + for (var entry : List.of( + new Object[]{"hold", LastlegOrderCommandDtos.HoldRequest.class}, + new Object[]{"saveManualNodes", LastlegOrderCommandDtos.ManualNodesRequest.class})) { + var method=LastlegOrderCommandService.class.getMethod((String)entry[0],String.class,long.class, + String.class,(Class)entry[1],String.class,String.class); + assertEquals(org.springframework.transaction.annotation.Isolation.READ_COMMITTED, + method.getAnnotation(org.springframework.transaction.annotation.Transactional.class).isolation()); + } + } + @Test void holdsOnlySelectedOrderWithVersionAndImmutableHistory() { LastlegOrder order = order(11L, 3); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 0c865f61d..f0b50d22f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -62,7 +62,7 @@ 普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 -尾程暂扣按真实入库阶段验收:`phase4-tail-order-controls` 在 PDA 入库前验证暂扣返回冲突并回查订单零漂移,继续人工节点及整柜计划转换;`phase4-overseas-pda-operations` 接收本轮完成库存后调用同模块的 `verifyReceivedOrderHold`,重新核对本轮客户、订单和 `INBOUND_COMPLETED` 状态,再执行暂扣、同键重放/异参冲突、解除。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。 +尾程暂扣按真实入库阶段验收:`phase4-tail-order-controls` 在 PDA 入库前验证暂扣返回冲突并回查订单零漂移,继续人工节点及整柜计划转换;`phase4-overseas-pda-operations` 接收本轮完成库存后调用同模块的 `verifyReceivedOrderHold`,重新核对本轮客户、订单和 `INBOUND_COMPLETED` 状态,再执行暂扣、同键重放/异参冲突、解除。第一轮暂扣和解除分别同时发送两条相同幂等键、相同载荷的命令,全部返回后核对响应相同、版本只增加一次和原始库存桶;第二轮继续验证可重复周期与独立冻结保留。并发限制为两条同命令,不并发创建资源;响应不明时等待两请求结束并保留样本,不盲目释放。该证据不替代暂扣与 PDA 拣货竞争同一库存的测试。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。 `pda-loading-link-regression.mjs` 通过总入口 `--modules pda-loading-link-regression` 运行,复用 `dropship-pallet-matrix.mjs` 的国内正式订单、测量、封托、订舱、证据和完成生产链,仅执行国内关联回归。覆盖单票加入后全移除换柜完成、多票共托反复换三个旧柜后完成、逐托即时提交及同键重放,以及一单多柜中仍有实际分配的未开始柜导致 `OWNER_READY/A0409`。每次重新创建带 marker 的业务数据,通过公开 HTTP 回查旧柜有效分配数、PDA 终态和入仓状态;不写业务数据库伪造前置。零分配残留由真实整托移除链观察,报告保存 `createdResources.pdaLoadingLinkRegression`。负向柜保留为 `COMPLETION_FAILED`,成功柜保留为 `COMPLETED`,不自动清理;失败报告保留且不得算通过。使用显式测试环境文件及 `--output reports/<本轮名称>.json`,须有真实 HTTP 写入授权;不启动海外收货或出库。 diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 6ae787023..bd552627d 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -178,6 +178,12 @@ test('fresh immutable IDs drive two full cycles and preserve independent HOLD ac await verifyReceivedOrderHold(context) const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof assert.equal(proof.status,'VERIFIED');assert.equal(proof.restored,true);assert.equal(order.version,8) + assert.equal(proof.concurrentCommands.length,2) + for(const command of proof.concurrentCommands) { + assert.equal(command.copies,2) + assert.deepEqual(command.replies[0],command.replies[1]) + assert.equal(command.replies[0].version,command.expectedVersion+1) + } assert.equal(proof.snapshots.C2_RELEASED.positions.find(row=>row.id==='22').holdPackages,1) assert.equal(proof.snapshots.AFTER.balances[0].availablePackages,4) assert.equal(calls.filter(c=>c.path.endsWith('/manual')).length,2) @@ -185,6 +191,41 @@ test('fresh immutable IDs drive two full cycles and preserve independent HOLD ac assert.equal(calls.filter(c=>c.method==='POST'&&c.path.endsWith('/inventory/release')).length,1) }) +test('hold duplicate requests are in flight together, with bounded concurrency of two',async()=>{ + const {context}=holdModel() + const originalRequest=context.request + let active=0,peak=0 + context.request=async(method,path,...args)=>{ + if(method!=='PUT'||!path.includes('/orders/')) return originalRequest(method,path,...args) + active++;peak=Math.max(peak,active) + try { await new Promise(resolve=>setImmediate(resolve));return await originalRequest(method,path,...args) } + finally {active--} + } + await verifyReceivedOrderHold(context) + assert.equal(peak,2) + assert.equal(active,0) +}) + +test('ambiguous parallel hold waits for its sibling and never starts recovery writes',async()=>{ + const {context,calls}=holdModel() + const originalRequest=context.request + let holdRequests=0,siblingSettled=false + context.request=async(method,path,body,...args)=>{ + if(method==='PUT' && body?.held && ++holdRequests<=2) { + if(holdRequests===1) throw new Error('lost response') + await new Promise(resolve=>setImmediate(resolve)) + const response=await originalRequest(method,path,body,...args) + siblingSettled=true + return response + } + return originalRequest(method,path,body,...args) + } + await assert.rejects(verifyReceivedOrderHold(context),/lost response/) + assert.equal(siblingSettled,true) + assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof.uncertainWrite,true) + assert.ok(!calls.some(call=>call.path.endsWith('/inventory/release')||call.body?.held===false)) +}) + for(const fault of ['stale marker','no evidence','wrong immutable customer','missing position','unsafe bucket','not enough stock','moved source']) { test(`missing or unsafe fresh hold prerequisite blocks all writes: ${fault}`,async()=>{ const {context,calls}=holdModel(fault) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index 133fa0f2b..c54e81d68 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -372,7 +372,7 @@ export async function verifyReceivedOrderHold(context) { && originalBalances.every(row=>id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId) && originalPositions.every(row=>id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId)) const proof = owned.holdInventoryProof = { marker:context.marker, orderId, customerId, warehouseId, - balanceIds, positionIds, snapshots:{}, status:'RUNNING', restored:false } + balanceIds, positionIds, snapshots:{}, concurrentCommands:[], status:'RUNNING', restored:false } const orderPath = query(`${ORDERS}/${encodeURIComponent(orderId)}/hold`, {customerId}) const inventoryPath = '/api/platform/v1/last-leg/inventory' let uncertain = false, otherApplied = false, active = null, baseline, otherHeld, otherBody @@ -419,14 +419,24 @@ export async function verifyReceivedOrderHold(context) { } const stock = value => ({balances:value.balances.map(holdStableStock),positions:value.positions.map(holdStableStock)}) const expectStock = (actual,expected,label) => require(label,sameHoldFacts(stock(actual),stock(expected))) - const orderCommand = async (held,version,suffix) => { + const orderCommand = async (held,version,suffix,copies=1) => { const body={held,chargeable:held,reason:`${context.marker} ${suffix}`,expectedVersion:Number(version)} const headers={'Idempotency-Key':`${attemptKey}-${suffix}`} - const data=await request('PUT',held?'暂扣本轮单票尾程订单':'解除本轮单票尾程暂扣',orderPath,body,headers) + // Settle both writes before inspecting or recovering: one rejected promise does not + // mean its sibling has stopped modifying the order. + const outcomes=await Promise.allSettled(Array.from({length:copies},()=> + request('PUT',held?'暂扣本轮单票尾程订单':'解除本轮单票尾程暂扣',orderPath,body,headers))) + const failed=outcomes.find(result=>result.status==='rejected') + if(failed) { if(copies>1) uncertain=true;throw failed.reason } + const replies=outcomes.map(result=>result.value),data=replies[0] // A malformed successful response is not sufficient authority for a recovery mutation. if(id(data?.orderId)!==orderId || data.held!==held || Number(data.version)!==Number(version)+1) uncertain=true require('暂扣命令返回本订单预期版本及计费状态',!uncertain && data.chargeable===held && (held || data.reason==null)) active=held?{version:Number(data.version),reason:body.reason}:null + if(copies>1) { + proof.concurrentCommands.push({held,copies,expectedVersion:Number(version),replies}) + require('并发同键命令返回相同订单状态和唯一新版本',replies.every(reply=>sameHoldFacts(reply,data))) + } return {body,headers,data} } const releaseOther = async () => { @@ -461,7 +471,7 @@ export async function verifyReceivedOrderHold(context) { const allHeld=shiftHoldStock(otherHeld,null,true) for(let cycle=1;cycle<=2;cycle++) { const before=await read(`C${cycle}_BEFORE`) - const held=await orderCommand(true,before.order.version,`F32-C${cycle}-HOLD`) + const held=await orderCommand(true,before.order.version,`F32-C${cycle}-HOLD`,cycle===1?2:1) const during=await read(`C${cycle}_HELD`) require('暂扣只推进单票版本并保留计费原因',during.order.detained===true && Number(during.order.version)===Number(held.data.version) && during.order.holdReason===held.body.reason && during.order.heldChargeable===true) @@ -473,7 +483,7 @@ export async function verifyReceivedOrderHold(context) { await reject('暂扣尾程订单不能生成人工计划',`${PLANS}/manual`,manualPlanBody(representative,held.data.version,context.marker), {'Idempotency-Key':`${attemptKey}-F32-C${cycle}-PLAN`},'POST') require('人工计划拒绝后暂扣与库存不变',sameHoldFacts(await read(`C${cycle}_PLAN_REJECTED`),during)) - const released=await orderCommand(false,during.order.version,`F32-C${cycle}-RELEASE`) + const released=await orderCommand(false,during.order.version,`F32-C${cycle}-RELEASE`,cycle===1?2:1) const after=await read(`C${cycle}_RELEASED`) require('解除暂扣清空计费与原因并推进版本',after.order.detained===false && after.order.heldChargeable===false && after.order.holdReason==null && Number(after.order.version)===Number(released.data.version)) -- 2.54.0 From 274ecac47404adb2db20ff7793aa8deba40ce569 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 12:15:12 +0800 Subject: [PATCH 094/176] fix(scripts): validate current vehicle task registry --- scripts/agents-index-contract.ps1 | 28 ++++++++----------- .../agents-index-registry-contract.test.mjs | 10 +++---- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 64d1dbb6d..81a8449e2 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -201,24 +201,20 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务 Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' # BEGIN CURRENT TEST REGISTRY CONTRACT function Assert-TestTaskRegistry([string]$registry) { - Assert-Contains $registry '用户 2026-09-30 的明确请求重新创建' '测试任务明确创建依据' - Assert-Contains $registry '按4个创建,你直接负责汇总' '测试任务精确数量授权' - Assert-Contains $registry '不根据旧台账恢复或派工' '测试任务禁止历史复用' - Assert-Contains $registry 'task_id=01a0d0da-130f-7d43-97b5-9c76abc1b5f1' '测试所属总控' - Assert-Contains $registry 'projectId=c64729ff-6710-43c6-8ef2-d84e9666cc1e' '测试保存项目' - Assert-Contains $registry '`C:/Users/BY-OY/Desktop/code/chongchong-global`' '测试工作目录' - Assert-Contains $registry '不操作开发或生产环境' '测试授权环境边界' - $expectedIds = @( - '01a0f14c-9262-7b43-990a-0e3beddcdd8a', - '01a0f14c-962f-73c3-b1aa-9df555b91586', - '01a0f14c-9a66-7f61-bc2b-6ad17a6c940a', - '01a0f14c-9e33-7a01-903d-a5febd50774b' - ) - $actualIds = @([regex]::Matches($registry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) + $current = ($registry -split '(?m)^## 2026-10-03 归档与重新规划')[0] + Assert-Contains $current '## 2026-10-03 车辆管理四任务协作(当前)' '当前车辆协作入口' + Assert-Contains $current '确认创建三个任务,我们当前目标调整一下,加上自己,一共4个任务' '测试任务精确数量授权' + Assert-Contains $current '车辆总控:当前对话 `01a0f19d-3f0b-7ae0-a860-c0d1d2e84475`' '测试所属总控' + Assert-Contains $current '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试保存项目' + Assert-Contains $current '`C:/Users/BY-OY/Desktop/code/chongchong-global`' '测试工作目录' + Assert-Contains $current '不混入开发环境' '测试授权环境边界' + Assert-Contains $current '其他旧任务仍保持归档,不自动恢复' '测试任务禁止历史恢复' + $expectedIds = @('01a0ffcf-7150-7db3-9d8c-108e568c12b1','01a0ffcf-eea3-7111-b30b-4be63547ac33','01a0ffd0-2a4d-7210-bad8-a790cd965e8a') + $actualIds = @([regex]::Matches($current, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) $script:checked++ - if ($actualIds.Count -ne 4 -or @($actualIds | Select-Object -Unique).Count -ne 4 -or + if ($actualIds.Count -ne 3 -or @($actualIds | Select-Object -Unique).Count -ne 3 -or @($expectedIds | Where-Object { $actualIds -notcontains $_ }).Count -gt 0) { - Add-Failure '测试登记必须恰为本轮明确创建的四个唯一任务' + Add-Failure '测试当前登记必须恰为三个独立任务,加车辆总控共四个任务' } Assert-NotContains $registry '当前无登记任务' '测试登记不得混入过期清空状态' Assert-NotContains $registry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' diff --git a/scripts/agents-index-registry-contract.test.mjs b/scripts/agents-index-registry-contract.test.mjs index 67b75e092..7ac952cca 100644 --- a/scripts/agents-index-registry-contract.test.mjs +++ b/scripts/agents-index-registry-contract.test.mjs @@ -23,12 +23,12 @@ test('actual explicitly authorized TEST registry passes the production validator assert.deepEqual(failures(registry), []) }) for (const [name, mutate] of [ - ['missing creation authorization', s => s.replace('按4个创建,你直接负责汇总', '安排任务')], - ['wrong controller', s => s.replace('task_id=01a0d0da-130f-7d43-97b5-9c76abc1b5f1', 'task_id=unknown')], - ['wrong project', s => s.replace('projectId=c64729ff-6710-43c6-8ef2-d84e9666cc1e', 'projectId=wrong')], + ['missing creation authorization', s => s.replace('确认创建三个任务,我们当前目标调整一下,加上自己,一共4个任务', '安排任务')], + ['wrong controller', s => s.replace('车辆总控:当前对话 `01a0f19d-3f0b-7ae0-a860-c0d1d2e84475`', 'task_id=unknown')], + ['wrong project', s => s.replace('`c64729ff-6710-43c6-8ef2-d84e9666cc1e`', 'projectId=wrong')], ['wrong workspace', s => s.replace('`C:/Users/BY-OY/Desktop/code/chongchong-global`', '`C:/Users/BY-OY/Desktop/code/chongchong-global-dev`')], - ['duplicate task', s => s.replace('01a0f14c-962f-73c3-b1aa-9df555b91586', '01a0f14c-9262-7b43-990a-0e3beddcdd8a')], - ['unregistered replacement', s => s.replace('01a0f14c-962f-73c3-b1aa-9df555b91586', '00000000-0000-0000-0000-000000000000')], + ['duplicate task', s => s.replace('01a0ffcf-eea3-7111-b30b-4be63547ac33', '01a0ffcf-7150-7db3-9d8c-108e568c12b1')], + ['unregistered replacement', s => s.replace('01a0ffcf-eea3-7111-b30b-4be63547ac33', '00000000-0000-0000-0000-000000000000')], ['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'], -- 2.54.0 From cb6c54ffe551faea69c782778da3d8438ec46ea6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 12:38:04 +0800 Subject: [PATCH 095/176] feat(fleet): integrate costs quotes operations and in-transit handovers --- .../common/event/EventDeliveryRoute.java | 2 + .../com/baiyue/common/event/EventTypes.java | 1 + .../baiyue/common/event/DeliveryAckTest.java | 4 +- .../common/web/EventDeliveryMetrics.java | 2 +- deploy/sql/README.md | 7 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 99 +- deploy/sql/海外区域统一增量-MySQL8.sql | 134 ++- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 4 + .../file/service/BusinessFileStorage.java | 2 +- .../openapi/finance-vehicle-voucher-v1.yaml | 10 + .../file/service/VehicleCarePhotoTest.java | 6 +- finance/AGENTS.md | 1 + .../fleet/InternalVehicleCostController.java | 33 + .../fleet/PlatformVehicleCostController.java | 64 ++ .../fleet/VehicleCostClientConfiguration.java | 8 + .../fleet/VehicleCostExceptionHandler.java | 11 + .../finance/fleet/VehicleCostFileClient.java | 23 + .../finance/fleet/VehicleCostModels.java | 45 + .../finance/fleet/VehicleCostService.java | 230 +++++ .../finance/fleet/VehicleCostVouchers.java | 61 ++ .../FinanceDocumentNumberGenerator.java | 2 +- finance/src/main/resources/db/finance.sql | 59 ++ .../openapi/finance-vehicle-costs-v1.yaml | 287 ++++++ .../fleet/VehicleCostAuthorizationTest.java | 67 ++ .../fleet/VehicleCostHttpContractTest.java | 23 + .../fleet/VehicleCostPersistenceTest.java | 187 ++++ .../fleet/VehicleCostVouchersTest.java | 42 + .../FinanceDocumentNumberGeneratorTest.java | 11 + .../db/fleet-finance-permissions.sql | 14 + .../main/resources/db/lastleg-permissions.sql | 22 + lastleg-service/AGENTS.md | 6 +- .../analyze-lastleg-dependencies.test.mjs | 4 +- .../lastleg/client/DirectEventClient.java | 7 + .../client/FinanceBusinessEventClient.java | 3 + .../controller/EventOperationsController.java | 2 +- .../dispatch/fleet/DispatchFleetAdapter.java | 61 ++ .../fleet/PdaFleetHandoverController.java | 22 + .../fleet/VehicleCareFinanceAdapter.java | 58 ++ .../FleetHandoverInspectionService.java | 94 ++ .../fleet/exception/FleetHandoverModels.java | 37 + .../exception/FleetHandoverPdaQuery.java | 71 ++ .../fleet/exception/FleetHandoverPolicy.java | 104 ++ .../exception/FleetHandoverRepository.java | 49 + .../fleet/exception/FleetHandoverService.java | 208 ++++ .../fleet/exception/FleetTransitEvidence.java | 10 + .../PlatformFleetHandoverController.java | 44 + .../PlatformVehicleCareFinanceController.java | 27 + .../maintenance/VehicleCareFinanceClient.java | 13 + .../VehicleCareFinanceGateway.java | 17 + .../maintenance/VehicleCareFinanceModels.java | 22 + .../VehicleCareFinanceService.java | 149 +++ .../fleet/maintenance/VehicleCareMapper.java | 4 + .../fleet/maintenance/VehicleCareService.java | 8 +- .../VehicleCareSupplierClient.java | 13 + .../fleet/quote/FleetQuoteDirectory.java | 29 + .../lastleg/fleet/quote/FleetQuoteModels.java | 32 + .../fleet/quote/FleetQuoteRepository.java | 39 + .../fleet/quote/FleetQuoteService.java | 87 ++ .../fleet/quote/FleetQuoteSupplierClient.java | 14 + .../quote/PlatformFleetQuoteController.java | 41 + .../PlatformTripOperationsController.java | 25 + ...latformTripOperationsRecordController.java | 28 + .../fleet/reporting/TripOperationsCharts.java | 32 + .../fleet/reporting/TripOperationsModels.java | 38 + .../reporting/TripOperationsRecordModels.java | 19 + .../TripOperationsRecordRepository.java | 33 + .../TripOperationsRecordService.java | 59 ++ .../reporting/TripOperationsRepository.java | 32 + .../reporting/TripOperationsService.java | 136 +++ .../fleet/trip/FleetDispatchGateway.java | 4 + .../fleet/trip/FleetTripCostBasis.java | 59 ++ .../fleet/trip/FleetTripCostModels.java | 29 + .../fleet/trip/FleetTripCostRepository.java | 93 ++ .../fleet/trip/FleetTripCostService.java | 143 +++ .../fleet/trip/FleetTripExecutionService.java | 3 +- .../trip/PlatformFleetTripCostController.java | 37 + .../fleet/trip/VehicleFulfillmentMapper.java | 12 +- .../fleet/trip/VehicleFulfillmentService.java | 6 +- .../UnloadCompletedOutboxRepository.java | 3 +- .../src/main/resources/db/lastleg.sql | 132 +++ .../resources/openapi/fleet-handovers-v1.yaml | 290 ++++++ .../openapi/fleet-operations-v1.yaml | 928 ++++++++++++++++++ .../resources/openapi/fleet-quotes-v1.yaml | 887 +++++++++++++++++ .../openapi/fleet-trip-costs-v1.yaml | 183 ++++ .../openapi/fleet-vehicle-fulfillment-v1.yaml | 4 +- .../openapi/fleet-vehicle-records-v1.yaml | 72 +- .../lastleg/client/DirectEventClientTest.java | 23 + .../fleet/VehicleCareFinanceAdapterTest.java | 48 + .../exception/FleetHandoverContractTest.java | 61 ++ .../exception/FleetHandoverPdaQueryTest.java | 74 ++ .../exception/FleetHandoverPolicyTest.java | 98 ++ .../FleetHandoverTransactionTest.java | 171 ++++ .../exception/FleetTransitEvidenceTest.java | 116 +++ .../VehicleCareFinancePersistenceTest.java | 125 +++ .../VehicleCarePersistenceTest.java | 6 +- .../fleet/quote/FleetQuoteDirectoryTest.java | 26 + .../quote/FleetQuotePersistenceTest.java | 68 ++ .../TripOperationsRecordPersistenceTest.java | 56 ++ .../reporting/TripOperationsServiceTest.java | 70 ++ .../fleet/trip/FleetTripCostContractTest.java | 77 ++ .../trip/FleetTripCostPersistenceTest.java | 189 ++++ .../fleet/trip/FleetTripPersistenceTest.java | 3 + .../trip/VehicleFulfillmentQueryTest.java | 40 +- .../repository/OutboxLeaseDatabaseTest.java | 12 +- .../lastleg-slice-dependencies.json | 5 + .../initialization-baseline-contract.test.mjs | 54 +- 107 files changed, 7304 insertions(+), 42 deletions(-) create mode 100644 file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java create mode 100644 finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java create mode 100644 iam/src/main/resources/db/fleet-finance-permissions.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java index 17d68e3a6..485e1563d 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java @@ -17,6 +17,8 @@ public record EventDeliveryRoute(String serviceName, String path, boolean accept new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false); case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false); + case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false); default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType); }; } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 81829864f..cb25e5b9b 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -16,6 +16,7 @@ public final class EventTypes { public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; + public static final String COMPANY_VEHICLE_COST_SUBMITTED = "CompanyVehicleCostSubmitted"; public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java index b681a6a47..1961163a1 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -29,7 +29,7 @@ class DeliveryAckTest { @Test void staleCannotAcknowledgeWalletCommandsResultsOrIndependentFees() { for (String type : new String[]{EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED}) { var event = event(type); assertThatThrownBy(() -> DeliveryAck.of(event, DeliveryAck.Outcome.STALE).requireMatches(event)) .isInstanceOf(IllegalArgumentException.class); @@ -39,6 +39,8 @@ class DeliveryAckTest { @Test void mixedLastlegOutboxRoutesWalletToFinanceAndUnloadingToFirstleg() { + assertThat(EventDeliveryRoute.forEventType(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED)) + .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false)); assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_REQUESTED)) .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false)); assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service"); diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java index 188b63a79..1a293ed2b 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java @@ -17,7 +17,7 @@ public final class EventDeliveryMetrics { FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING", List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")), LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING", - List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested")), + List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested", "CompanyVehicleCostSubmitted")), FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")), FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted")); public final String service, table, status, processing; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 974734e2d..d552636ee 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -6,12 +6,13 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 145 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 152 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 +- 车辆成本、报价、出车实测及在途交接表对应Lastleg模块基线;公司车辆费用三表对应Finance模块基线。公司财务权限源为`iam/src/main/resources/db/fleet-finance-permissions.sql`,已合入国内统一种子,不以模块文件代替完整空库初始化。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -24,7 +25,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/145`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/152`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 678bca7f8..84a641170 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5073,6 +5073,65 @@ CREATE TABLE fin_subject ( UNIQUE KEY uk_fin_subject_code (subject_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台财务主体;首期仅一个有效主体'; +CREATE TABLE fin_vehicle_cost ( + id BIGINT NOT NULL COMMENT '公司费用主键', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体', + cost_no VARCHAR(64) NOT NULL COMMENT '公司车辆费用编号', + source_type VARCHAR(32) NOT NULL COMMENT '费用来源类型', + source_no VARCHAR(64) NOT NULL COMMENT '来源业务编号', + source_version BIGINT NOT NULL COMMENT '来源快照版本', + source_hash VARCHAR(71) NOT NULL COMMENT '来源内容摘要', + source_json JSON NOT NULL COMMENT '原币及折算不可变来源快照', + supplier_id BIGINT NOT NULL COMMENT '收款供应商标识', + supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称快照', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY公司费用金额', + paid_amount DECIMAL(17,2) NOT NULL DEFAULT 0 COMMENT 'CNY累计实际付款', + status VARCHAR(32) NOT NULL COMMENT '入账与付款状态', + record_version BIGINT NOT NULL COMMENT '并发版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_no (cost_no), + UNIQUE KEY uk_fin_vehicle_cost_source (subject_id,source_type,source_no), + KEY idx_fin_vehicle_cost_page (subject_id,updated_at,id), + CONSTRAINT fk_fin_vehicle_cost_subject FOREIGN KEY (subject_id) REFERENCES fin_subject(id), + CONSTRAINT chk_fin_vehicle_cost_amount CHECK (amount>0 AND paid_amount>=0 AND paid_amount<=amount), + CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0), + CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务'; + +CREATE TABLE fin_vehicle_cost_revision ( + id BIGINT NOT NULL COMMENT '公司车辆费用历史主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + record_version BIGINT NOT NULL COMMENT '费用版本', + action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作', + request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要', + operator_id BIGINT NOT NULL COMMENT '操作人或系统标识', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照', + reason VARCHAR(1000) NULL COMMENT '更正或操作说明', + snapshot_json JSON NOT NULL COMMENT '不可变费用快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version), + UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key), + CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计'; + +CREATE TABLE fin_vehicle_cost_payment ( + id BIGINT NOT NULL COMMENT '公司车辆付款主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额', + payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照', + created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_payment_no (payment_no), + KEY idx_fin_vehicle_payment_cost (cost_id,id), + CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id), + CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录'; + CREATE TABLE fin_wallet_account ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', @@ -6709,6 +6768,44 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107350,0,'PLATFORM','MENU',0,'platform:fleet:quote:list','车队报价','/overseas-master-data/fleet-quotes','GET',980,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107350,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107351,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:create','新增车队报价',NULL,'POST',981,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107351,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107352,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:edit','编辑车队报价',NULL,'PUT',982,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107352,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107353,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:status','启停车队报价',NULL,'PATCH',983,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107353,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107354,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:delete','删除车队报价',NULL,'DELETE',984,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107354,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107355,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:import','导入车队报价',NULL,'POST',985,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107355,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107356,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:export','导出车队报价',NULL,'GET',986,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107356,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107360,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107380,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107381,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:approve','公司车辆成本入账',NULL,'POST',1011,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107381,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107382,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:void','作废未付公司车辆成本',NULL,'POST',1012,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107382,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107383,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:pay','登记公司车辆付款',NULL,'POST',1013,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107383,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107384,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:upload','上传公司车辆付款凭证',NULL,'POST',1014,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107384,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107385,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107340,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107341,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-edit','更正车次直接成本',NULL,'PUT',971,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107341,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107314,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cancel','取消车次',NULL,'POST',944,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107310,0,CURRENT_TIMESTAMP(3)); @@ -8158,4 +8255,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c21872057..677e05e0b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2795,6 +2795,138 @@ CREATE TABLE ll_fleet_trip_dispatch ( CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +CREATE TABLE ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; + CREATE TABLE ll_fleet_trip_history ( id BIGINT NOT NULL COMMENT '内部主键', trip_id BIGINT NOT NULL COMMENT '车次内部引用', @@ -3251,4 +3383,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 145 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 152 AS initialized_tables; diff --git a/file/AGENTS.md b/file/AGENTS.md index 9b30f0dd8..75e2f76ea 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -16,6 +16,7 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | +| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | | 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 | 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 bc8bd6d68..10a375cfd 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -17,6 +17,7 @@ enum BusinessFileCategory { CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FINANCE_VEHICLE_VOUCHER(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -61,6 +62,9 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { + if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); + } if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台"); } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index f7ac07c88..11ee988ae 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -65,7 +65,7 @@ public class BusinessFileStorage { extension = ".xlsx"; } else { if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type)) - throw bad("运维照片仅支持JPG、PNG"); + throw bad("图片附件仅支持JPG、PNG"); if (formatPolicy == BusinessFileCategory.FormatPolicy.LICENSE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) throw bad("驾驶证附件仅支持PDF、JPG、PNG"); extension = TYPES.get(type); diff --git a/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml b/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml new file mode 100644 index 000000000..8f6989db4 --- /dev/null +++ b/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.3 +info: + title: 公司车辆付款凭证归属 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController内部上传、元数据及内容接口,不增加公网路径。 + businessType为FINANCE_VEHICLE_VOUCHER,tenantId必须为0,businessId为Finance公司费用内部主键。 + 仅非空JPG/PNG,最多10MiB,同时校验MIME及文件签名;File按平台、业务类型、费用ID及文件ID复合校验。 + Finance校验list/upload/pay权限及费用归属;上传不是付款,不复用客户FINANCE_VOUCHER或车辆维修照片。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java index fb314beeb..cafa7fd4d 100644 --- a/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java +++ b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java @@ -9,8 +9,10 @@ import static org.assertj.core.api.Assertions.*; class VehicleCarePhotoTest { @TempDir Path root; - @Test void recordPhotosArePlatformOwnedAndRequireRealImageBytes() throws Exception { - var category = BusinessFileCategory.require("FLEET_VEHICLE_RECORD_PHOTO"); + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"FLEET_VEHICLE_RECORD_PHOTO", "FINANCE_VEHICLE_VOUCHER"}) + void recordPhotosAndCompanyVouchersArePlatformOwnedAndRequireRealImageBytes(String type) throws Exception { + var category = BusinessFileCategory.require(type); category.validateOwner(0); assertThatThrownBy(() -> category.validateOwner(7)).hasMessageContaining("平台"); assertThatThrownBy(() -> category.validateOwner(-1)).hasMessageContaining("平台"); diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 36a1a97b9..c59e2fdb1 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -14,6 +14,7 @@ | 目标 | 首选入口 | |---|---| | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | +| 公司车辆成本 | `fleet/`及`src/main/resources/openapi/finance-vehicle-costs-v1.yaml`;独立平台主体费用、入账和付款,不进入客户账务。编号VC/VP,凭证FINANCE_VEHICLE_VOUCHER;维修/救援由Lastleg完整快照事件接入 | | 平台财务凭证上传 | `PlatformFinanceController.java` 的 `POST /vouchers`、`client/FinanceFileClient.java`;文件类型固定 `FINANCE_VOUCHER`,业务 ID/客户范围使用当前客户 ID | | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | diff --git a/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java b/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java new file mode 100644 index 000000000..4e93d58ea --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java @@ -0,0 +1,33 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@RestController +@RequestMapping("/internal/finance/v1/vehicle-costs") +public class InternalVehicleCostController { + private final VehicleCostService service; + private final String token; + public InternalVehicleCostController(VehicleCostService service, @Value("${baiyue.security.internal-service-token:}") String token) { + this.service = service; this.token = token; + } + @PostMapping("/events") + public ApiResponse receive(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied, + @RequestBody EventEnvelope event) { authorize(supplied); return ApiResponse.success(service.receive(event)); } + @PostMapping("/sources/query") + public ApiResponse> sources(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied, + @Valid @RequestBody VehicleCostModels.SourceQuery request) { authorize(supplied); return ApiResponse.success(service.sources(request)); } + private void authorize(String supplied) { + if (token == null || token.isBlank() || supplied == null + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), supplied.getBytes(StandardCharsets.UTF_8))) + throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java b/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java new file mode 100644 index 000000000..00c2aaa2f --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java @@ -0,0 +1,64 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/platform/finance/v1/vehicle-costs") +public class PlatformVehicleCostController { + private final VehicleCostService costs; + private final VehicleCostVouchers vouchers; + public PlatformVehicleCostController(VehicleCostService costs, VehicleCostVouchers vouchers) { this.costs = costs; this.vouchers = vouchers; } + @GetMapping + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> list(@Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.list(query)); } + @GetMapping("/{costNo}") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse detail(@PathVariable String costNo) { return ApiResponse.success(costs.detail(costNo)); } + @GetMapping("/{costNo}/history") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> history(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.history(costNo, query)); } + @GetMapping("/{costNo}/payments") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> payments(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.payments(costNo, query)); } + @PostMapping("/{costNo}/approve") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:approve')") + public ApiResponse approve(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) { + return ApiResponse.success(costs.approve(costNo, user.userId(), name(user), key, request)); + } + @PostMapping("/{costNo}/void") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:void')") + public ApiResponse voidCost(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) { + return ApiResponse.success(costs.voidCost(costNo, user.userId(), name(user), key, request)); + } + @PostMapping("/{costNo}/payments") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:pay')") + public ApiResponse pay(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Payment request) { + return ApiResponse.success(costs.pay(costNo, user.userId(), name(user), key, request)); + } + @PostMapping(value="/{costNo}/vouchers", consumes="multipart/form-data") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:upload')") + public ApiResponse upload(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(vouchers.upload(costs.voucherOwner(costNo), user.userId(), file)); + } + @GetMapping("/{costNo}/vouchers/{fileId}/content") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ResponseEntity download(@PathVariable String costNo, @PathVariable long fileId) { + return vouchers.download(costs.voucherOwner(costNo), fileId); + } + private static String name(AuthenticatedUser user) { + if (user.displayName() != null && !user.displayName().isBlank()) return user.displayName(); + return user.username() == null || user.username().isBlank() ? "—" : user.username(); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java new file mode 100644 index 000000000..5efb62c2e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.finance.fleet; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods=false) +@EnableFeignClients(clients=VehicleCostFileClient.class) +public class VehicleCostClientConfiguration { } diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java new file mode 100644 index 000000000..a1ea497b6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java @@ -0,0 +1,11 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.controller.FinanceExceptionHandler; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** Keep Finance business errors and diagnostic identifiers on the dedicated fleet endpoints. */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages="com.baiyue.finance.fleet") +public class VehicleCostExceptionHandler extends FinanceExceptionHandler { } diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java new file mode 100644 index 000000000..341b47120 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; + +@FeignClient(name="file-service", contextId="financeVehicleCostFiles", url="${baiyue.clients.file.url:}", + path="/internal/file/v1/business-files/0/FINANCE_VEHICLE_VOUCHER") +public interface VehicleCostFileClient { + @PostMapping(value="/{costId}", consumes="multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long costId, @RequestPart("file") MultipartFile file); + @GetMapping("/{costId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long costId, @PathVariable long fileId); + @GetMapping("/{costId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long costId, @PathVariable long fileId); + record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java new file mode 100644 index 000000000..226ae849c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java @@ -0,0 +1,45 @@ +package com.baiyue.finance.fleet; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** Company-owned costs are deliberately separate from customer fee sources and settlements. */ +public final class VehicleCostModels { + private VehicleCostModels() { } + public static final String EVENT_TYPE = "CompanyVehicleCostSubmitted"; + public record Source(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType, + @NotBlank @Size(max=64) String sourceNo, @Positive long sourceVersion, + @NotBlank @Pattern(regexp="[1-9][0-9]*") String vehicleId, + @NotBlank @Size(max=64) String vehiclePlate, @Size(max=64) String tripNo, + @Positive long supplierId, @NotBlank @Size(max=200) String feeName, + @NotNull LocalDate businessDate, @NotBlank @Pattern(regexp="CNY|USD") String originalCurrency, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=12,fraction=2) BigDecimal originalAmount, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=9,fraction=6) BigDecimal exchangeRate, + @NotBlank @Pattern(regexp="[0-9]{4}-[0-9]{2}") String exchangeRateMonth, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount, + @Size(max=1000) String remark) { } + public record Command(@NotNull @PositiveOrZero Long expectedVersion, @Size(max=1000) String reason) { } + public record Payment(@NotNull @PositiveOrZero Long expectedVersion, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount, + @NotNull Instant paidAt, @NotBlank @Pattern(regexp="ONLINE|BANK_TRANSFER|CASH|OTHER") String method, + @Size(max=128) String referenceNo, + @NotEmpty @Size(max=20) List<@NotBlank @Pattern(regexp="[1-9][0-9]*") String> voucherFileIds, + @Size(max=1000) String remark) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record SourceKey(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType, + @NotBlank @Size(max=64) String sourceNo) { } + public record SourceQuery(@NotEmpty @Size(max=100) List<@Valid SourceKey> sources) { } + public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, + String status, BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { } + public record History(long recordVersion, String action, String reason, String operatorName, + Instant occurredAt, Cost snapshot) { } + public record Paid(String paymentNo, String currency, BigDecimal amount, Instant paidAt, String method, String referenceNo, + List voucherFileIds, String operatorName, Instant recordedAt, String remark) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java new file mode 100644 index 000000000..5e6ecaa6e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java @@ -0,0 +1,230 @@ +package com.baiyue.finance.fleet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.*; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validator; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.*; +import java.util.*; + +/** Only this company-cost ledger is written. Customer costs, settlements and wallets are untouched. */ +@Service +public class VehicleCostService { + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final Validator validator; + private final FinanceSupplierValidator suppliers; + private final FinanceDocumentNumberGenerator numbers; + private final VehicleCostVouchers vouchers; + private record Row(long id, long subjectId, VehicleCostModels.Cost cost) { } + private final RowMapper rowMapper = (r, ignored) -> { + var source = read(r.getString("source_json"), VehicleCostModels.Source.class); + var paid = r.getBigDecimal("paid_amount"); + var status = r.getString("status"); + var outstanding = Set.of("APPROVED", "PARTIALLY_PAID", "PAID").contains(status) ? source.amount().subtract(paid) : null; + return new Row(r.getLong("id"), r.getLong("subject_id"), new VehicleCostModels.Cost(r.getString("cost_no"), + "PLATFORM_DEFAULT", source, r.getString("supplier_name"), "CNY", status, paid, outstanding, r.getLong("record_version"))); + }; + public VehicleCostService(JdbcTemplate jdbc, ObjectMapper json, Validator validator, + FinanceSupplierValidator suppliers, FinanceDocumentNumberGenerator numbers, VehicleCostVouchers vouchers) { + this.jdbc = jdbc; this.json = json; this.validator = validator; + this.suppliers = suppliers; this.numbers = numbers; this.vouchers = vouchers; + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public DeliveryAck receive(EventEnvelope event) { + try { event.requireValid(); } catch (RuntimeException error) { throw bad("公司车辆费用事件信封无效"); } + if (!VehicleCostModels.EVENT_TYPE.equals(event.eventType()) || event.eventVersion() != 1 + || !"0".equals(event.tenantId()) || !"COMPANY_VEHICLE_COST".equals(event.aggregateType()) + || !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) + throw bad("公司车辆费用事件类型、归属或摘要无效"); + if (event.payload().has("customerId")) throw bad("公司车辆费用不得携带客户归属"); + var source = read(event.payload().toString(), VehicleCostModels.Source.class); + validate(source); + if (!event.aggregateId().equals(source.sourceType() + ":" + source.sourceNo()) || event.aggregateVersion() != source.sourceVersion()) + throw bad("费用来源与事件聚合版本不一致"); + long subject = subject(true); + var priorEvent = jdbc.queryForList("SELECT payload_hash,status FROM fin_inbox_event WHERE source_system='LASTLEG' AND event_id=? FOR UPDATE", event.eventId()); + if (!priorEvent.isEmpty()) { + if (!event.payloadHash().equals(priorEvent.get(0).get("payload_hash"))) throw conflict("同一事件对应不同公司费用内容"); + if (!"PROCESSED".equals(priorEvent.get(0).get("status"))) throw conflict("费用事件尚未处理完成"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); + } + String sourceHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(source))); + var current = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND source_type=? AND source_no=? FOR UPDATE", + rowMapper, subject, source.sourceType(), source.sourceNo()); + Row row; + if (!current.isEmpty()) { + row = current.get(0); + long version = row.cost().source().sourceVersion(); + if (source.sourceVersion() < version) throw conflict("费用来源版本已过期"); + if (source.sourceVersion() == version) { + var storedHash = jdbc.queryForObject("SELECT source_hash FROM fin_vehicle_cost WHERE id=?", String.class, row.id()); + if (!sourceHash.equals(storedHash)) throw conflict("同一费用来源版本对应不同内容"); + inbox(event, row.id()); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); + } + if (!Set.of("PENDING", "VOIDED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) + throw conflict("已入账费用须先作废;已付款费用不能替换"); + String supplier = suppliers.requireActive(source.supplierId(), null).name(); + jdbc.update("UPDATE fin_vehicle_cost SET source_version=?,source_hash=?,source_json=?,supplier_id=?,supplier_name=?,amount=?,status='PENDING',record_version=record_version+1,updated_at=? WHERE id=?", + source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), row.id()); + row = required(row.cost().costNo(), false); + } else { + String supplier = suppliers.requireActive(source.supplierId(), null).name(); + long id = IdWorker.getId(); + String no = numbers.next("VC"); + jdbc.update("INSERT INTO fin_vehicle_cost(id,subject_id,cost_no,source_type,source_no,source_version,source_hash,source_json,supplier_id,supplier_name,amount,paid_amount,status,record_version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,0,'PENDING',0,?,?)", + id, subject, no, source.sourceType(), source.sourceNo(), source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), now()); + row = required(no, false); + } + audit(row, "RECEIVE", event.eventId(), event.payloadHash(), 0, "系统", "接收公司车辆费用来源"); + inbox(event, row.id()); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); + } + + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult list(VehicleCostModels.Query query) { + validate(query); long subject = subject(false); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost WHERE subject_id=?", Long.class, subject); + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? ORDER BY updated_at DESC,id DESC LIMIT ? OFFSET ?", + rowMapper, subject, query.size(), query.offset()); + return new PageResult<>(rows.stream().map(Row::cost).toList(), query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + @Transactional(readOnly=true) + public VehicleCostModels.Cost detail(String no) { return required(no, false).cost(); } + @Transactional(readOnly=true) + public List sources(VehicleCostModels.SourceQuery query) { + validate(query); long subject = subject(false); + String predicates = String.join(" OR ", Collections.nCopies(query.sources().size(), "(source_type=? AND source_no=?)")); + var args = new ArrayList(); args.add(subject); + for (var key : query.sources()) { args.add(key.sourceType()); args.add(key.sourceNo()); } + return jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND (" + predicates + ")", rowMapper, args.toArray()).stream().map(Row::cost).toList(); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult history(String no, VehicleCostModels.Query query) { + validate(query); long id = required(no, false).id(); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_revision WHERE cost_id=?", Long.class, id); + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost_revision WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?", + (r, ignored) -> new VehicleCostModels.History(r.getLong("record_version"), r.getString("action"), r.getString("reason"), + r.getString("operator_name"), r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + read(r.getString("snapshot_json"), VehicleCostModels.Cost.class)), id, query.size(), query.offset()); + return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult payments(String no, VehicleCostModels.Query query) { + validate(query); long id = required(no, false).id(); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_payment WHERE cost_id=?", Long.class, id); + var rows = jdbc.query("SELECT payment_json FROM fin_vehicle_cost_payment WHERE cost_id=? ORDER BY id DESC LIMIT ? OFFSET ?", + (r, ignored) -> read(r.getString("payment_json"), VehicleCostModels.Paid.class), id, query.size(), query.offset()); + return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost approve(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) { + return transition(no, operator, operatorName, key, command, "APPROVE"); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost voidCost(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) { + return transition(no, operator, operatorName, key, command, "VOID"); + } + private VehicleCostModels.Cost transition(String no, long operator, String name, String key, VehicleCostModels.Command command, String action) { + validate(command); subject(true); var row = required(no, true); + String hash = commandHash(no, command); if (replay(row, operator, action, key, hash)) return row.cost(); + requireVersion(row, command.expectedVersion()); + if (action.equals("APPROVE")) { + if (!"PENDING".equals(row.cost().status())) throw conflict("仅待入账费用可以入账"); + suppliers.requireActive(row.cost().source().supplierId(), null); + } else if (!Set.of("PENDING", "APPROVED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) { + throw conflict("已付款或已作废费用不能直接作废"); + } + jdbc.update("UPDATE fin_vehicle_cost SET status=?,record_version=record_version+1,updated_at=? WHERE id=?", + action.equals("APPROVE") ? "APPROVED" : "VOIDED", now(), row.id()); + var changed = required(no, false); + audit(changed, action, key, hash, operator, name, text(command.reason(), action.equals("APPROVE") ? "确认公司车辆费用入账" : "作废公司车辆费用")); + return changed.cost(); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost pay(String no, long operator, String name, String key, VehicleCostModels.Payment request) { + validate(request); subject(true); var row = required(no, true); + String hash = commandHash(no, request); if (replay(row, operator, "PAY", key, hash)) return row.cost(); + requireVersion(row, request.expectedVersion()); + if (!Set.of("APPROVED", "PARTIALLY_PAID").contains(row.cost().status())) throw conflict("仅已入账且未付清费用可以登记付款"); + if (request.paidAt().isAfter(Instant.now())) throw bad("实际付款时间不能晚于当前时间"); + if (request.amount().compareTo(row.cost().outstandingAmount()) > 0) throw conflict("付款金额不得超过未付余额"); + suppliers.requireActive(row.cost().source().supplierId(), null); + vouchers.validate(row.id(), request.voucherFileIds()); + var payment = new VehicleCostModels.Paid(numbers.next("VP"), "CNY", request.amount(), request.paidAt(), request.method(), + request.referenceNo(), List.copyOf(request.voucherFileIds()), text(name, "—"), Instant.now(), request.remark()); + jdbc.update("INSERT INTO fin_vehicle_cost_payment(id,cost_id,payment_no,amount,payment_json,created_at) VALUES(?,?,?,?,?,?)", + IdWorker.getId(), row.id(), payment.paymentNo(), request.amount(), write(payment), now()); + var paid = row.cost().paidAmount().add(request.amount()); + jdbc.update("UPDATE fin_vehicle_cost SET paid_amount=?,status=?,record_version=record_version+1,updated_at=? WHERE id=?", + paid, paid.compareTo(row.cost().source().amount()) == 0 ? "PAID" : "PARTIALLY_PAID", now(), row.id()); + var changed = required(no, false); + audit(changed, "PAY", key, hash, operator, name, text(request.remark(), "登记公司车辆费用实际付款")); + return changed.cost(); + } + public long voucherOwner(String no) { return required(no, false).id(); } + + private void validate(Object value) { + if (value == null || !validator.validate(value).isEmpty()) throw bad("公司车辆费用参数不完整或格式无效"); + if (value instanceof VehicleCostModels.Source source) { + if ("TRIP_DIRECT".equals(source.sourceType()) && (source.tripNo() == null || source.tripNo().isBlank())) + throw bad("车次直接成本须保留真实车次号"); + if (!source.exchangeRateMonth().equals(source.businessDate().toString().substring(0, 7))) + throw bad("费用业务日期或汇率月份无效"); + if ("CNY".equals(source.originalCurrency()) && source.exchangeRate().compareTo(BigDecimal.ONE) != 0) + throw bad("CNY费用汇率必须为1"); + if (source.originalAmount().multiply(source.exchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(source.amount()) != 0) + throw bad("公司车辆费用原币及折算金额不守恒"); + } + } + private long subject(boolean lock) { + var ids = jdbc.queryForList("SELECT id FROM fin_subject WHERE subject_code='PLATFORM_DEFAULT' AND status='ACTIVE'" + (lock ? " FOR UPDATE" : ""), Long.class); + if (ids.size() != 1) throw conflict("平台财务主体不存在或未启用"); + return ids.get(0); + } + private Row required(String no, boolean lock) { + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND cost_no=?" + (lock ? " FOR UPDATE" : ""), rowMapper, subject(false), no); + if (rows.isEmpty()) throw new FinanceEventException("FIN_404", "公司车辆费用不存在"); + return rows.get(0); + } + private boolean replay(Row row, long operator, String action, String key, String hash) { + if (operator <= 0 || key == null || key.isBlank() || key.length() > 128) throw bad("缺少有效操作人或幂等键"); + var rows = jdbc.queryForList("SELECT cost_id,request_hash FROM fin_vehicle_cost_revision WHERE operator_id=? AND action=? AND request_key=?", + operator, action, key); + if (rows.isEmpty()) return false; + if (((Number) rows.get(0).get("cost_id")).longValue() != row.id() || !hash.equals(rows.get(0).get("request_hash"))) + throw conflict("幂等键已用于其他财务操作内容"); + return true; + } + private void audit(Row row, String action, String key, String hash, long operator, String name, String reason) { + jdbc.update("INSERT INTO fin_vehicle_cost_revision(id,cost_id,record_version,action,request_key,request_hash,operator_id,operator_name,reason,snapshot_json,occurred_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(), row.id(), row.cost().recordVersion(), action, key, hash, operator, text(name, "—"), reason, write(row.cost()), now()); + } + private void inbox(EventEnvelope event, long id) { + jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status,result_resource_id,processed_at) VALUES(?,'LASTLEG',?,?,?,'PROCESSED',?,?)", + event.eventId(), event.eventType(), write(event), event.payloadHash(), id, now()); + } + private static void requireVersion(Row row, long version) { if (row.cost().recordVersion() != version) throw conflict("公司车辆费用版本已变化"); } + private String commandHash(String no, Object request) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(Map.of("costNo", no, "request", request)))); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("公司费用快照无法保存", error); } } + private T read(String value, Class type) { try { return json.readerFor(type).with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(value); } catch (JsonProcessingException error) { throw bad("公司费用快照格式无效"); } } + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } + private static String text(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); } + private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); } + private static FinanceEventException conflict(String message) { return new FinanceEventException("FIN_409", message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java new file mode 100644 index 000000000..e447e45a8 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java @@ -0,0 +1,61 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.service.FinanceEventException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +public class VehicleCostVouchers { + private final VehicleCostFileClient files; + private final String token; + public VehicleCostVouchers(VehicleCostFileClient files, @Value("${baiyue.security.internal-service-token:}") String token) { + this.files = files; this.token = token; + } + public void validate(long costId, List ids) { + if (ids == null || ids.isEmpty() || ids.size() > 20 || new HashSet<>(ids).size() != ids.size()) throw bad("付款凭证不能为空或重复"); + for (var id : ids) metadata(costId, fileId(id)); + } + public VehicleCostFileClient.Metadata upload(long costId, long operator, MultipartFile file) { + requireToken(); + var response = call(() -> files.upload(token, operator, costId, file)); + if (response == null || !response.successful()) throw unavailable(); + return requireOwner(costId, response.data(), null); + } + public VehicleCostFileClient.Metadata metadata(long costId, long fileId) { + requireToken(); + var response = call(() -> files.metadata(token, costId, fileId)); + if (response == null || !response.successful()) throw unavailable(); + return requireOwner(costId, response.data(), fileId); + } + public ResponseEntity download(long costId, long fileId) { + var metadata = metadata(costId, fileId); + var response = call(() -> files.download(token, costId, fileId)); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null + || response.getBody().length != metadata.sizeBytes() || response.getHeaders().getContentType() == null + || !metadata.contentType().equalsIgnoreCase(response.getHeaders().getContentType().toString())) throw unavailable(); + return response; + } + private static VehicleCostFileClient.Metadata requireOwner(long costId, VehicleCostFileClient.Metadata value, Long expected) { + if (value == null || value.fileId() == null || value.fileId() <= 0 || expected != null && !expected.equals(value.fileId()) + || !"PLATFORM".equals(value.ownerScope()) || !Long.valueOf(0).equals(value.tenantId()) + || !"FINANCE_VEHICLE_VOUCHER".equals(value.businessType()) || !Long.toString(costId).equals(value.businessId()) + || value.contentType() == null || !Set.of("image/jpeg", "image/png").contains(value.contentType()) || value.sizeBytes() == null || value.sizeBytes() <= 0) + throw bad("公司车辆付款凭证不存在或归属不匹配"); + return value; + } + private static long fileId(String value) { + try { long id = Long.parseLong(value); if (id > 0) return id; } catch (RuntimeException ignored) { } + throw bad("付款凭证编号无效"); + } + private void requireToken() { if (token == null || token.isBlank()) throw unavailable(); } + private static T call(java.util.function.Supplier request) { + try { return request.get(); } catch (feign.FeignException unavailable) { throw unavailable(); } + } + private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); } + private static FinanceEventException unavailable() { return new FinanceEventException("FIN_503", "公司车辆付款凭证服务不可用"); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java index d9c02ac8a..d9557200c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -17,7 +17,7 @@ import java.util.Set; public class FinanceDocumentNumberGenerator { private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC"); + private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP"); private static final long MAX_DAILY_SEQUENCE = 9_999L; private final JdbcTemplate jdbc; diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 4d0480d87..9c443bc26 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -94,6 +94,65 @@ VALUES ON DUPLICATE KEY UPDATE subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0; +CREATE TABLE fin_vehicle_cost ( + id BIGINT NOT NULL COMMENT '公司费用主键', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体', + cost_no VARCHAR(64) NOT NULL COMMENT '公司车辆费用编号', + source_type VARCHAR(32) NOT NULL COMMENT '费用来源类型', + source_no VARCHAR(64) NOT NULL COMMENT '来源业务编号', + source_version BIGINT NOT NULL COMMENT '来源快照版本', + source_hash VARCHAR(71) NOT NULL COMMENT '来源内容摘要', + source_json JSON NOT NULL COMMENT '原币及折算不可变来源快照', + supplier_id BIGINT NOT NULL COMMENT '收款供应商标识', + supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称快照', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY公司费用金额', + paid_amount DECIMAL(17,2) NOT NULL DEFAULT 0 COMMENT 'CNY累计实际付款', + status VARCHAR(32) NOT NULL COMMENT '入账与付款状态', + record_version BIGINT NOT NULL COMMENT '并发版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_no (cost_no), + UNIQUE KEY uk_fin_vehicle_cost_source (subject_id,source_type,source_no), + KEY idx_fin_vehicle_cost_page (subject_id,updated_at,id), + CONSTRAINT fk_fin_vehicle_cost_subject FOREIGN KEY (subject_id) REFERENCES fin_subject(id), + CONSTRAINT chk_fin_vehicle_cost_amount CHECK (amount>0 AND paid_amount>=0 AND paid_amount<=amount), + CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0), + CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务'; + +CREATE TABLE fin_vehicle_cost_revision ( + id BIGINT NOT NULL COMMENT '公司车辆费用历史主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + record_version BIGINT NOT NULL COMMENT '费用版本', + action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作', + request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要', + operator_id BIGINT NOT NULL COMMENT '操作人或系统标识', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照', + reason VARCHAR(1000) NULL COMMENT '更正或操作说明', + snapshot_json JSON NOT NULL COMMENT '不可变费用快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version), + UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key), + CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计'; + +CREATE TABLE fin_vehicle_cost_payment ( + id BIGINT NOT NULL COMMENT '公司车辆付款主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额', + payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照', + created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_payment_no (payment_no), + KEY idx_fin_vehicle_payment_cost (cost_id,id), + CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id), + CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录'; + CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml b/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml new file mode 100644 index 000000000..c971dd7eb --- /dev/null +++ b/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml @@ -0,0 +1,287 @@ +openapi: 3.0.3 +info: + title: 公司车辆运营成本 + version: 1.0.0 + description: >- + 公司费用归平台财务主体PLATFORM_DEFAULT,不进入客户费用池、客户账单、客户应付单或客户钱包。 + 来源接收只生成PENDING;独立入账后才产生应付,付款只登记有凭证的实际CNY支付。 + 原币支持CNY/USD,来源提供业务月份有效汇率与不可变折算快照;Finance复核金额守恒。 + 未付款可作废;已付款禁止直接改写、作废或替换。更高来源版本仅在PENDING/VOIDED且未付款时接收。 +security: + - bearerAuth: [] +x-integration-required: + status: 源码合同,仍待共享集成与总控编译验证,不代表可部署 + numbers: FinanceDocumentNumberGenerator登记VC公司费用与VP付款业务号前缀 + file: BusinessFileCategory登记FINANCE_VEHICLE_VOUCHER;平台0、费用ID复合归属、非空JPG/PNG、10MiB、签名校验 + event: CompanyVehicleCostSubmitted v1;LASTLEG直投内部事件入口;aggregateType=COMPANY_VEHICLE_COST,aggregateId=sourceType:sourceNo,aggregateVersion=sourceVersion;平台信封tenantId为字符串0,不携带customerId + delivery: 注册EventTypes/EventDeliveryRoute及Lastleg Relay;只有匹配eventId/payloadHash的APPLIED或DUPLICATE代表接收完成,不能显示为已入账/已付款 + lastleg: 提交时锁真实来源并冻结版本、供应商、金额、月份汇率;映射与Outbox同事务提交;禁止把维修照片或reportedCost自动解释为付款证据 + sql: + fin_vehicle_cost: + columns: id BIGINT PK, subject_id BIGINT, cost_no VARCHAR(64), source_type VARCHAR(32), source_no VARCHAR(64), source_version BIGINT, source_hash VARCHAR(71), source_json JSON, supplier_id BIGINT, supplier_name VARCHAR(200), amount DECIMAL(17,2), paid_amount DECIMAL(17,2), status VARCHAR(32), record_version BIGINT, created_at DATETIME(3), updated_at DATETIME(3) + unique: [cost_no, 'subject_id + source_type + source_no'] + checks: 正数主体和供应商;amount>0;0<=paid_amount<=amount;状态PENDING/APPROVED/PARTIALLY_PAID/PAID/VOIDED;无customer_id/tenant_id + indexes: [subject_id + updated_at + id] + fin_vehicle_cost_revision: + columns: id BIGINT PK, cost_id BIGINT, record_version BIGINT, action VARCHAR(32), request_key VARCHAR(128), request_hash VARCHAR(71), operator_id BIGINT, operator_name VARCHAR(128), reason VARCHAR(1000), snapshot_json JSON, occurred_at DATETIME(3) + unique: ['cost_id + record_version', 'operator_id + action + request_key'] + description: RECEIVE/APPROVE/VOID/PAY不可变审计及幂等记录,和主记录及付款同事务提交 + fin_vehicle_cost_payment: + columns: id BIGINT PK, cost_id BIGINT, payment_no VARCHAR(64), amount DECIMAL(17,2), payment_json JSON, created_at DATETIME(3) + unique: [payment_no] + indexes: [cost_id + id] + description: 不可变实际付款凭证快照;amount>0;付款与余额、审计同事务 +paths: + /api/platform/finance/v1/vehicle-costs: + get: + description: platform:finance:vehicle-cost:list;只查询公司成本 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + /api/platform/finance/v1/vehicle-costs/{costNo}: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/history: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list;不可变版本与当时金额快照 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + /api/platform/finance/v1/vehicle-costs/{costNo}/approve: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:approve;PENDING入账为APPROVED,复核供应商;不是付款 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: {$ref: '#/components/requestBodies/Command'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/void: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:void;仅未付款PENDING/APPROVED可作废;更高来源版本可重新待入账 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: {$ref: '#/components/requestBodies/Command'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/payments: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list;CNY付款历史 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + post: + description: platform:finance:vehicle-cost:pay;仅APPROVED/PARTIALLY_PAID,付款金额不超过未付余额,必须有本费用的公司付款凭证 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Payment'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/vouchers: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:upload;上传不是付款,不生成付款流水 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {description: ApiResponse,data为本费用专属凭证元数据} + /api/platform/finance/v1/vehicle-costs/{costNo}/vouchers/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/CostNo'} + - {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + get: + description: platform:finance:vehicle-cost:list;校验平台、文件类别和费用归属 + responses: + '200': {description: 图片字节及File响应头} + /internal/finance/v1/vehicle-costs/events: + post: + security: [{internalService: []}] + description: 内部身份;同eventId异载荷拒绝,同来源版本异内容拒绝,旧来源版本拒绝;业务与Inbox同事务提交后ACK + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [eventId, eventType, eventVersion, occurredAt, tenantId, aggregateType, aggregateId, aggregateVersion, traceId, correlationId, payload, payloadHash] + properties: + eventId: {type: string, format: uuid} + eventType: {type: string, enum: [CompanyVehicleCostSubmitted]} + eventVersion: {type: integer, enum: [1]} + occurredAt: {type: string, format: date-time} + tenantId: {type: string, enum: ['0']} + aggregateType: {type: string, enum: [COMPANY_VEHICLE_COST]} + aggregateId: {type: string} + aggregateVersion: {type: integer, minimum: 1} + traceId: {type: string} + correlationId: {type: string} + payloadHash: {type: string} + payload: {$ref: '#/components/schemas/Source'} + responses: + '200': {description: 'ApiResponse;eventId、payloadHash与请求一致,outcome为APPLIED/DUPLICATE;仅表示接收完成'} + /internal/finance/v1/vehicle-costs/sources/query: + post: + security: [{internalService: []}] + description: 最多100组来源批量查询,未接收来源不返回;来源不存在不能解释为已付款或零成本 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sources] + properties: + sources: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [sourceType, sourceNo] + properties: + sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]} + sourceNo: {type: string, maxLength: 64} + responses: + '200': {description: 'ApiResponse>'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + internalService: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + CostNo: {name: costNo, in: path, required: true, schema: {type: string}} + Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + Key: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + requestBodies: + Command: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, maxLength: 1000} + responses: + Cost: + description: 业务响应;FIN_400参数/归属、FIN_404不存在、FIN_409状态/版本/幂等冲突 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: {$ref: '#/components/schemas/Cost'} + Page: + description: ApiResponse;data含records/page/size/total/pages,列表Cost、历史History或付款Paid + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + type: object + properties: + page: {type: integer, format: int64} + size: {type: integer, format: int64} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + records: + type: array + items: + oneOf: + - {$ref: '#/components/schemas/Cost'} + - {$ref: '#/components/schemas/History'} + - {$ref: '#/components/schemas/Paid'} + schemas: + Envelope: + type: object + properties: + code: {type: string} + message: {type: string} + requestId: {type: string} + traceId: {type: string} + data: {type: object} + Source: + type: object + additionalProperties: false + required: [sourceType, sourceNo, sourceVersion, vehicleId, vehiclePlate, supplierId, feeName, businessDate, originalCurrency, originalAmount, exchangeRate, exchangeRateMonth, amount] + properties: + sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]} + sourceNo: {type: string, maxLength: 64} + sourceVersion: {type: integer, format: int64, minimum: 1} + vehicleId: {type: string, pattern: '^[1-9][0-9]*$'} + vehiclePlate: {type: string, maxLength: 64} + tripNo: {type: string, nullable: true, maxLength: 64, description: TRIP_DIRECT必须提供真实车次号} + supplierId: {type: integer, format: int64, minimum: 1} + feeName: {type: string, maxLength: 200} + businessDate: {type: string, format: date} + originalCurrency: {type: string, enum: [CNY, USD]} + originalAmount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01} + exchangeRate: {type: number, minimum: 0, exclusiveMinimum: true, description: CNY必须为1;USD来自业务月份有效汇率} + exchangeRateMonth: {type: string, pattern: '^[0-9]{4}-[0-9]{2}$'} + amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY折算金额,与原币乘汇率四舍五入到分严格核平} + remark: {type: string, nullable: true, maxLength: 1000} + Cost: + type: object + properties: + costNo: {type: string} + subjectCode: {type: string, enum: [PLATFORM_DEFAULT]} + source: {$ref: '#/components/schemas/Source'} + supplierName: {type: string} + currency: {type: string, enum: [CNY]} + status: {type: string, enum: [PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]} + paidAmount: {type: number} + outstandingAmount: {type: number, nullable: true, description: 未入账或已作废时为空,不能当已知零应付} + recordVersion: {type: integer, format: int64} + Payment: + type: object + required: [expectedVersion, amount, paidAt, method, voucherFileIds] + properties: + expectedVersion: {type: integer, format: int64, minimum: 0} + amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY实际付款} + paidAt: {type: string, format: date-time} + method: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER]} + referenceNo: {type: string, maxLength: 128} + voucherFileIds: {type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: {type: string, pattern: '^[1-9][0-9]*$'}} + remark: {type: string, maxLength: 1000} + History: + type: object + properties: + recordVersion: {type: integer, format: int64} + action: {type: string, enum: [RECEIVE, APPROVE, VOID, PAY]} + reason: {type: string} + operatorName: {type: string} + occurredAt: {type: string, format: date-time} + snapshot: {$ref: '#/components/schemas/Cost'} + Paid: + type: object + properties: + paymentNo: {type: string} + currency: {type: string, enum: [CNY]} + amount: {type: number} + paidAt: {type: string, format: date-time} + method: {type: string} + referenceNo: {type: string, nullable: true} + voucherFileIds: {type: array, items: {type: string}} + operatorName: {type: string} + recordedAt: {type: string, format: date-time} + remark: {type: string, nullable: true} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java new file mode 100644 index 000000000..8183ac460 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java @@ -0,0 +1,67 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCostAuthorizationTest { + @Test void actualMethodSecurityDoesNotLetReadersApprovePayOrVoid() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformVehicleCostController.class); + var service = context.getBean(VehicleCostService.class); + var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); when(user.displayName()).thenReturn("财务人员"); + var request = new VehicleCostModels.Command(0L, null); + login("platform:finance:vehicle-cost:list"); controller.detail("VC-ONE"); + assertThatThrownBy(() -> controller.approve("VC-ONE", user, "approve-one", request)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:approve"); controller.approve("VC-ONE", user, "approve-one", request); + assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:pay"); controller.pay("VC-ONE", user, "pay-one", null); + assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:void"); controller.voidCost("VC-ONE", user, "void-one", request); + login(); assertThatThrownBy(() -> controller.detail("VC-ONE")).isInstanceOf(AccessDeniedException.class); + verify(service).detail("VC-ONE"); verify(service).approve("VC-ONE", 8L, "财务人员", "approve-one", request); + verify(service).pay("VC-ONE", 8L, "财务人员", "pay-one", null); + verify(service).voidCost("VC-ONE", 8L, "财务人员", "void-one", request); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + private static void login(String... permissions) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused", + java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); + } + @Test void companyCostActionsHaveIndependentPermissionsAndReadsUseListPermission() throws Exception { + assertThat(PlatformVehicleCostController.class.getMethod("detail", String.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:finance:vehicle-cost:list')"); + for (var entry : java.util.Map.of("approve", "approve", "voidCost", "void").entrySet()) { + assertThat(PlatformVehicleCostController.class.getMethod(entry.getKey(), String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Command.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:" + entry.getValue() + "')"); + } + assertThat(PlatformVehicleCostController.class.getMethod("pay", String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Payment.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:pay')"); + } + @Test void internalEndpointsRejectMissingOrWrongServiceIdentityBeforeServiceCalls() { + var service = mock(VehicleCostService.class); + var controller = new InternalVehicleCostController(service, "test-internal-token"); + assertThatThrownBy(() -> controller.receive(null, null)).hasMessageContaining("令牌"); + assertThatThrownBy(() -> controller.sources("wrong", null)).hasMessageContaining("令牌"); + assertThatThrownBy(() -> new InternalVehicleCostController(service, "").receive("", null)).hasMessageContaining("令牌"); + verifyNoInteractions(service); + } + @Configuration @EnableMethodSecurity static class Config { + @Bean VehicleCostService costs() { return mock(VehicleCostService.class); } + @Bean VehicleCostVouchers vouchers() { return mock(VehicleCostVouchers.class); } + @Bean PlatformVehicleCostController controller(VehicleCostService costs, VehicleCostVouchers vouchers) { + return new PlatformVehicleCostController(costs, vouchers); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java new file mode 100644 index 000000000..9b126e4c3 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.hamcrest.Matchers.*; + +class VehicleCostHttpContractTest { + @Test void fleetBusinessErrorsKeepFinanceCodesAndDiagnosticIds() throws Exception { + var service = mock(VehicleCostService.class); + when(service.detail("VC-ONE")).thenThrow(new FinanceEventException("FIN_409", "公司车辆费用版本已变化")); + var mvc = MockMvcBuilders.standaloneSetup(new PlatformVehicleCostController(service, mock(VehicleCostVouchers.class))) + .setControllerAdvice(new VehicleCostExceptionHandler()).build(); + mvc.perform(get("/api/platform/finance/v1/vehicle-costs/VC-ONE")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("FIN_409")) + .andExpect(jsonPath("$.message").value("公司车辆费用版本已变化")) + .andExpect(jsonPath("$.requestId", not(emptyOrNullString()))) + .andExpect(jsonPath("$.traceId", not(emptyOrNullString()))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java new file mode 100644 index 000000000..eb31525cd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java @@ -0,0 +1,187 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WarehouseSupplierClient; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.validation.Validation; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCostPersistenceTest { + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private JdbcTemplate jdbc; + private VehicleCostService service; + private FinanceSupplierValidator suppliers; + private VehicleCostVouchers vouchers; + private ValidatorFactory validators; + private static final VehicleCostModels.Query PAGE = new VehicleCostModels.Query(1, 20); + @BeforeEach void setup() { + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fin_subject(id BIGINT PRIMARY KEY,subject_code VARCHAR(64),status VARCHAR(20))"); + jdbc.update("INSERT INTO fin_subject VALUES(1,'PLATFORM_DEFAULT','ACTIVE')"); + jdbc.execute("CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),payload CLOB,payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,processed_at TIMESTAMP(3),UNIQUE(source_system,event_id))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost(id BIGINT PRIMARY KEY,subject_id BIGINT NOT NULL,cost_no VARCHAR(64) NOT NULL UNIQUE,source_type VARCHAR(32) NOT NULL,source_no VARCHAR(64) NOT NULL,source_version BIGINT NOT NULL,source_hash VARCHAR(71) NOT NULL,source_json CLOB NOT NULL,supplier_id BIGINT NOT NULL,supplier_name VARCHAR(200) NOT NULL,amount DECIMAL(17,2) NOT NULL,paid_amount DECIMAL(17,2) NOT NULL,status VARCHAR(32) NOT NULL,record_version BIGINT NOT NULL,created_at TIMESTAMP(3),updated_at TIMESTAMP(3),UNIQUE(subject_id,source_type,source_no),CHECK(paid_amount>=0 AND paid_amount<=amount))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost_revision(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,record_version BIGINT NOT NULL,action VARCHAR(32) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,operator_id BIGINT NOT NULL,operator_name VARCHAR(128) NOT NULL,reason VARCHAR(1000),snapshot_json CLOB NOT NULL,occurred_at TIMESTAMP(3),UNIQUE(cost_id,record_version),UNIQUE(operator_id,action,request_key))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost_payment(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,payment_no VARCHAR(64) NOT NULL UNIQUE,amount DECIMAL(17,2) NOT NULL,payment_json CLOB NOT NULL,created_at TIMESTAMP(3))"); + suppliers = mock(FinanceSupplierValidator.class); + when(suppliers.requireActive(8L, null)).thenReturn(new WarehouseSupplierClient.SupplierSnapshot(8L, "维修供应商", null, "ACTIVE")); + vouchers = mock(VehicleCostVouchers.class); + var numbers = mock(FinanceDocumentNumberGenerator.class); var sequence = new AtomicInteger(); + when(numbers.next(anyString())).thenAnswer(call -> call.getArgument(0) + "-" + sequence.incrementAndGet()); + validators = Validation.buildDefaultValidatorFactory(); + var target = new VehicleCostService(jdbc, json, validators.getValidator(), suppliers, numbers, vouchers); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (VehicleCostService) proxy.getProxy(); + } + @AfterEach void close() { validators.close(); } + private VehicleCostModels.Source source(long version, String amount) { + return new VehicleCostModels.Source("REPAIR", "VR-ONE", version, "101", "TEST-PLATE", "TR-ONE", 8, + "车辆维修", LocalDate.of(2026, 1, 1), "USD", new BigDecimal(amount), new BigDecimal("7.000000"), "2026-01", + new BigDecimal(amount).multiply(new BigDecimal("7")).setScale(2), null); + } + private EventEnvelope event(VehicleCostModels.Source source) { + var payload = json.valueToTree(source); + return new EventEnvelope(UUID.randomUUID().toString(), VehicleCostModels.EVENT_TYPE, 1, Instant.now(), "0", + "COMPANY_VEHICLE_COST", source.sourceType() + ":" + source.sourceNo(), source.sourceVersion(), null, + UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + private VehicleCostModels.Cost receive() { service.receive(event(source(1, "10"))); return service.list(PAGE).records().get(0); } + private VehicleCostModels.Payment payment(long version, String amount) { + return new VehicleCostModels.Payment(version, new BigDecimal(amount), Instant.parse("2026-01-02T00:00:00Z"), + "BANK_TRANSFER", "PAY-REF", List.of("11"), null); + } + @Test void acceptedSourceIsNotBookedOrPaidAndReplayRequiresTheSameHash() { + var first = event(source(1, "10")); + assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + var other = event(source(1, "11")); + var conflict = new EventEnvelope(first.eventId(), other.eventType(), 1, other.occurredAt(), "0", other.aggregateType(), other.aggregateId(), 1, + null, other.traceId(), other.correlationId(), null, other.payload(), other.payloadHash()); + assertThatThrownBy(() -> service.receive(conflict)).hasMessageContaining("不同公司费用"); + var row = service.list(PAGE).records().get(0); + assertThat(row.status()).isEqualTo("PENDING"); assertThat(row.outstandingAmount()).isNull(); + assertThat(row.paidAmount()).isZero(); assertThat(row.source().amount()).isEqualByComparingTo("70"); + assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isEqualTo(1); + } + @Test void missingVersionCannotApproveOrPayTheInitialRecord() throws Exception { + var row = receive(); + var missing = json.readValue("{}", VehicleCostModels.Command.class); + assertThat(missing.expectedVersion()).isNull(); + assertThatThrownBy(() -> service.approve(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数"); + assertThatThrownBy(() -> service.voidCost(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数"); + var pay = new VehicleCostModels.Payment(null, BigDecimal.ONE, Instant.parse("2026-01-02T00:00:00Z"), "CASH", null, List.of("11"), null); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "missing-version", pay)).hasMessageContaining("参数"); + assertThat(service.detail(row.costNo()).status()).isEqualTo("PENDING"); + assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1); + } + @Test void failedSourceAuditCannotAcknowledgeOrPersistHalfAnEvent() { + var event = event(source(1, "10")); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_source_audit CHECK(action<>'RECEIVE')"); + assertThatThrownBy(() -> service.receive(event)).isInstanceOf(RuntimeException.class); + assertThat(service.list(PAGE).total()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero(); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision DROP CONSTRAINT reject_source_audit"); + assertThat(service.receive(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + assertThat(service.list(PAGE).total()).isEqualTo(1); + } + @Test void concurrentDuplicateEventsOnlyPersistOneSourceAndOneHistory() throws Exception { + var event = event(source(1, "10")); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> service.receive(event)); var b = executor.submit(() -> service.receive(event)); + assertThat(List.of(a.get(10, TimeUnit.SECONDS).outcome(), b.get(10, TimeUnit.SECONDS).outcome())) + .containsExactlyInAnyOrder(DeliveryAck.Outcome.APPLIED, DeliveryAck.Outcome.DUPLICATE); + } finally { executor.shutdownNow(); } + assertThat(service.list(PAGE).total()).isEqualTo(1); + } + @Test void partialAndFullPaymentConserveAmountsAndReplayNeverPaysTwice() { + var row = receive(); var no = row.costNo(); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "before-approve", payment(0, "1"))).hasMessageContaining("已入账"); + var approved = service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + assertThat(approved.outstandingAmount()).isEqualByComparingTo("70"); + var partial = service.pay(no, 1, "财务", "payment-one", payment(1, "20")); + assertThat(partial.status()).isEqualTo("PARTIALLY_PAID"); assertThat(partial.outstandingAmount()).isEqualByComparingTo("50"); + assertThat(service.pay(no, 1, "财务", "payment-one", payment(1, "20"))).isEqualTo(partial); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "payment-one", payment(1, "21"))).hasMessageContaining("幂等键"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "too-much", payment(2, "51"))).hasMessageContaining("未付余额"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "stale-version", payment(1, "1"))).hasMessageContaining("版本"); + var paid = service.pay(no, 1, "财务", "payment-two", payment(2, "50")); + assertThat(paid.status()).isEqualTo("PAID"); assertThat(paid.outstandingAmount()).isZero(); + assertThat(service.payments(no, PAGE).total()).isEqualTo(2); + assertThatThrownBy(() -> service.voidCost(no, 1, "财务", "void-paid", new VehicleCostModels.Command(3L, null))).hasMessageContaining("已付款"); + assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("不能替换"); + } + @Test void unpaidBookedCostRequiresVoidBeforeNewVersionAndRetainsSnapshots() { + var row = receive(); var no = row.costNo(); + service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("先作废"); + service.voidCost(no, 1, "财务", "void-one", new VehicleCostModels.Command(1L, "金额重新核对")); + service.receive(event(source(2, "20"))); + var latest = service.detail(no); + assertThat(latest.status()).isEqualTo("PENDING"); assertThat(latest.source().amount()).isEqualByComparingTo("140"); + assertThat(service.history(no, PAGE).records()).hasSize(4); + assertThat(service.history(no, PAGE).records().get(3).snapshot().source().amount()).isEqualByComparingTo("70"); + assertThatThrownBy(() -> service.receive(event(source(1, "10")))).hasMessageContaining("过期"); + } + @Test void auditFailureRollsBackPaymentAndBalances() { + var row = receive(); var no = row.costNo(); + service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_payment_audit CHECK(action<>'PAY')"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "failing-payment", payment(1, "20"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(no).paidAmount()).isZero(); assertThat(service.detail(no).recordVersion()).isEqualTo(1); + assertThat(service.payments(no, PAGE).records()).isEmpty(); + } + @Test void voucherOrSupplierFailureDoesNotCreatePayment() { + var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + doThrow(new IllegalStateException("凭证归属不符")).when(vouchers).validate(anyLong(), anyList()); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("归属"); + assertThat(service.payments(row.costNo(), PAGE).total()).isZero(); + reset(vouchers); when(suppliers.requireActive(8L, null)).thenThrow(new IllegalStateException("供应商不可用")); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("不可用"); + assertThat(service.detail(row.costNo()).paidAmount()).isZero(); + } + @Test void concurrentPaymentsCannotOverpay() throws Exception { + var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + var executor = Executors.newFixedThreadPool(2); + try { + Callable first = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-a", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } }; + Callable second = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-b", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } }; + var a = executor.submit(first); var b = executor.submit(second); + assertThat(List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + } finally { executor.shutdownNow(); } + assertThat(service.detail(row.costNo()).paidAmount()).isEqualByComparingTo("50"); + assertThat(service.detail(row.costNo()).outstandingAmount()).isEqualByComparingTo("20"); + } + @Test void rejectsClientScopeBadExchangeAndBadSourceBeforeWritingAnything() { + var valid = event(source(1, "10")); + var customer = new EventEnvelope(valid.eventId(), valid.eventType(), 1, valid.occurredAt(), "12", valid.aggregateType(), valid.aggregateId(), 1, + null, valid.traceId(), valid.correlationId(), null, valid.payload(), valid.payloadHash()); + assertThatThrownBy(() -> service.receive(customer)).hasMessageContaining("归属"); + var tree = (com.fasterxml.jackson.databind.node.ObjectNode) valid.payload().deepCopy(); tree.put("amount", 71); + var invalid = new EventEnvelope(UUID.randomUUID().toString(), valid.eventType(), 1, valid.occurredAt(), "0", valid.aggregateType(), valid.aggregateId(), 1, + null, valid.traceId(), valid.correlationId(), null, tree, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(tree))); + assertThatThrownBy(() -> service.receive(invalid)).hasMessageContaining("不守恒"); + assertThat(service.list(PAGE).total()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero(); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java new file mode 100644 index 000000000..32d0406e6 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java @@ -0,0 +1,42 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCostVouchersTest { + @Test void onlyPlatformFilesBoundToThisCompanyCostCanProvePayment() { + var client = mock(VehicleCostFileClient.class); + var service = new VehicleCostVouchers(client, "test-internal-token"); + when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success( + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L))); + service.validate(5, List.of("11")); + for (var wrong : List.of( + new VehicleCostFileClient.Metadata(11L, "CUSTOMER", 12L, "FINANCE_VOUCHER", "12", "payment.png", "image/png", 100L), + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "6", "payment.png", "image/png", 100L), + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FLEET_VEHICLE_RECORD_PHOTO", "5", "repair.png", "image/png", 100L))) { + when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(wrong)); + assertThatThrownBy(() -> service.validate(5, List.of("11"))).hasMessageContaining("归属"); + } + } + @Test void absentConfigurationOrDuplicateVouchersFailClosed() { + var client = mock(VehicleCostFileClient.class); + assertThatThrownBy(() -> new VehicleCostVouchers(client, "").validate(5, List.of("11"))).hasMessageContaining("不可用"); + assertThatThrownBy(() -> new VehicleCostVouchers(client, "test").validate(5, List.of("11", "11"))).hasMessageContaining("重复"); + verifyNoInteractions(client); + } + @Test void fileServiceBusinessErrorBodyCannotBeForwardedAsPaymentEvidence() { + var client = mock(VehicleCostFileClient.class); + var service = new VehicleCostVouchers(client, "test"); + when(client.metadata("test", 5, 11)).thenReturn(ApiResponse.success( + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L))); + when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + assertThatThrownBy(() -> service.download(5, 11)).hasMessageContaining("不可用"); + when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[100])); + assertThat(service.download(5, 11).getBody()).hasSize(100); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java index 26720351a..ba0177c12 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -56,4 +56,15 @@ class FinanceDocumentNumberGeneratorTest { .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); } + + @Test + void vehicleCostsAndPaymentsUseIndependentSequenceKeys() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L); + FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); + assertThat(generator.next("VC")).isEqualTo("VC-260818-0001"); + assertThat(generator.next("VP")).isEqualTo("VP-260818-0001"); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818")); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818")); + } } diff --git a/iam/src/main/resources/db/fleet-finance-permissions.sql b/iam/src/main/resources/db/fleet-finance-permissions.sql new file mode 100644 index 000000000..65a46f428 --- /dev/null +++ b/iam/src/main/resources/db/fleet-finance-permissions.sql @@ -0,0 +1,14 @@ +-- 公司车辆成本独立权限;仅初始化使用,不覆盖客户账务权限。 +USE baiyue_iam; +SET NAMES utf8mb4; + +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107380,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107381,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:approve','公司车辆成本入账',NULL,'POST',1011,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107381,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107382,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:void','作废未付公司车辆成本',NULL,'POST',1012,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107382,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107383,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:pay','登记公司车辆付款',NULL,'POST',1013,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107383,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107384,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:upload','上传公司车辆付款凭证',NULL,'POST',1014,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107384,0,CURRENT_TIMESTAMP(3)); diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 01fd80ecd..775750945 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -83,6 +83,20 @@ VALUES (5107316,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:release','结束车辆司机任务',NULL,'POST',946,'ACTIVE',1,0), (5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0), (5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0), + (5107350,0,'PLATFORM','MENU',0,'platform:fleet:quote:list','车队报价','/overseas-master-data/fleet-quotes','GET',980,'ACTIVE',1,0), + (5107351,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:create','新增车队报价',NULL,'POST',981,'ACTIVE',1,0), + (5107352,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:edit','编辑车队报价',NULL,'PUT',982,'ACTIVE',1,0), + (5107353,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:status','启停车队报价',NULL,'PATCH',983,'ACTIVE',1,0), + (5107354,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:delete','删除车队报价',NULL,'DELETE',984,'ACTIVE',1,0), + (5107355,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:import','导入车队报价',NULL,'POST',985,'ACTIVE',1,0), + (5107356,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:export','导出车队报价',NULL,'GET',986,'ACTIVE',1,0), + (5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0), + (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0), + (5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0), + (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0), + (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0), + (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0), + (5107341,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-edit','更正车次直接成本',NULL,'PUT',971,'ACTIVE',1,0), (5107330,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-create','新增外接预订',NULL,'POST',960,'ACTIVE',1,0), (5107331,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-edit','修改外接预订',NULL,'PUT',961,'ACTIVE',1,0), (5107332,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-confirm','确认外接承运',NULL,'POST',962,'ACTIVE',1,0), @@ -137,6 +151,14 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit') +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + DELETE role_permission FROM iam_role_permission role_permission JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 273532344..9bcf01a0b 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -75,7 +75,11 @@ ## 海外仓全流程开发路由 -- 车辆履约只读投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同 `src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml`。车辆路径下`deliveries`同时要求vehicle:list与delivery:list,`assignment-history`同时要求vehicle:list与trip:list;不新增权限码或跨库查询。计划派送归当前分配车辆,实际运输归不可变DEPART快照;客户与排车双键关联。更换读取相邻版本ASSIGN快照,首次分配不算更换,旧车/新车/仅换司机分别呈现,不按当前车牌猜历史。列表分页在只读重复读事务内核对;内部ID及整份客户快照不直接返回。 +- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。 +- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 + +- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 5d07260b4..d1adbc7cd 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -29,7 +29,9 @@ test('fleet stays a business slice and its policies have no infrastructure depen const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); - assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)), + assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' + && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'), 'dispatch must use fleet ports and guards, not its persistence mappers'); const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(incomingEdges.length > 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java index 4555c23df..c396cd388 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java @@ -27,6 +27,7 @@ public class DirectEventClient { EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event); case EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event)); case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event)); + case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED -> checkedVehicleCost(finance.vehicleCost(token, event)); default -> throw new EventDeliveryFailure(true, "Unsupported outbound event type: " + event.eventType()); }; } @@ -34,4 +35,10 @@ public class DirectEventClient { EventDeliveryFailure.requireSuccessful(response, FINANCE_PERMANENT_CODES); return response; } + private ApiResponse checkedVehicleCost(ApiResponse response) { + var permanentCodes = new java.util.HashSet<>(FINANCE_PERMANENT_CODES); + permanentCodes.addAll(Set.of("FIN_400", "FIN_404", "FIN_409")); + EventDeliveryFailure.requireSuccessful(response, permanentCodes); + return response; + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java index 96e35de55..797987c52 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java @@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.RequestHeader; @FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "finance-service", contextId = "FinanceBusinessEventClient", url = "${FINANCE_SERVICE_URL:${baiyue.clients.finance.url:}}") public interface FinanceBusinessEventClient { + @PostMapping("/internal/finance/v1/vehicle-costs/events") + ApiResponse vehicleCost(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); @PostMapping("/internal/finance/v1/wallet-command-events") ApiResponse wallet(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody EventEnvelope envelope); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java index bf1ab9909..08ea643a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java @@ -24,7 +24,7 @@ public class EventOperationsController { ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { guard = new EventOperationsTokenGuard(token); operations = new OutboxOperations(jdbc, transactions, - new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)), + new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED)), json -> { try { EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index ad328d63c..0c5a97102 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -174,6 +174,67 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason); } // Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone. + @Override + public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock) { + var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); + var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) + : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); + if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus()) + || dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0 + || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) + throw conflict("交接必须具有已完成出库的真实发运依据"); + var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) + : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); + var facts = new java.util.ArrayList(); + long packages = 0; + for (var row : rows) { + if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId()) + || !Objects.equals(row.getDispatchOrderId(), dispatch.getId()) + || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) + || !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId()) + || row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null + || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) + throw conflict("派送货物归属或执行状态与原始发运不一致"); + Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) + ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null; + if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null) + throw conflict("派送终态缺少真实签收或退回完成证据"); + String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null, + row.getDeliveryAddress(), row.getPostalCode()); + if (key == null) throw conflict("派送单缺少真实卸货目的地"); + var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(), + dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal); + facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(), + new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone()))); + packages = Math.addExact(packages, row.getPackageCount()); + } + if (facts.isEmpty() || packages != dispatch.getShippedPackageCount()) + throw conflict("派送明细未核平本次实发货物"); + return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(), + dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts)); + } + + @Override + public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) { + var current = transitEvidence(evidence.dispatchNo(), true); + if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对"); + for (var fact : evidence.deliveries()) { + if (fact.cargo().terminalAt() != null) continue; + var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo()); + if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null) + throw conflict("派送货物已变化,请重新核对交接"); + if (deliveries.update(null, new LambdaUpdateWrapper() + .eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId()) + .eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status()) + .isNull(DeliveryOrder::getSignedAt) + .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) + .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1) + .set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1) + throw conflict("派送交接保存失败,请刷新核对"); + } + } + private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); } private static boolean originallySigned(DeliveryOrder order) { return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java new file mode 100644 index 000000000..6d10de600 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/dispatches") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaFleetHandoverController { + private final FleetHandoverPdaQuery service; + public PdaFleetHandoverController(FleetHandoverPdaQuery service) { this.service = service; } + @GetMapping("/{dispatchNo}/handover-status") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { + var identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.status(identity.overseasWarehouseId(), dispatchNo)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java new file mode 100644 index 000000000..ce32c0452 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.financefee.fleet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +@Component +public class VehicleCareFinanceAdapter implements VehicleCareFinanceGateway { + private final OverseasFeeCurrencyConverter converter; + private final LastlegEventOutboxMapper outbox; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + public VehicleCareFinanceAdapter(OverseasFeeCurrencyConverter converter, LastlegEventOutboxMapper outbox, JdbcTemplate jdbc, ObjectMapper json) { + this.converter = converter; this.outbox = outbox; this.jdbc = jdbc; this.json = json; + } + @Override + public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) { + var converted = converter.convert(currency, amount, businessDate); + return new Conversion(converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount()); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public void publish(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventTypes.COMPANY_VEHICLE_COST_SUBMITTED.equals(envelope.eventType()) || !"0".equals(envelope.tenantId())) + throw new IllegalArgumentException("车辆公司成本Outbox事件类型或平台归属无效"); + var event = new LastlegEventOutbox(); event.setId(IdWorker.getId()); event.setCustomerId("0"); + event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); + event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(envelope.correlationId()); + try { event.setPayloadJson(json.writeValueAsString(envelope)); } + catch (JsonProcessingException error) { throw new IllegalStateException("车辆公司成本事件无法保存", error); } + event.setStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (outbox.insert(event) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "财务投递记录保存失败"); + } + @Override @Transactional(readOnly=true) + public Map deliveryStates(List keys) { + if (keys.size() > 100) throw new IllegalArgumentException("车辆成本投递查询最多100个事件"); + if (keys.isEmpty()) return Map.of(); + var result = new HashMap(); + jdbc.query("SELECT event_key,status FROM ll_event_outbox WHERE event_key IN (" + String.join(",", Collections.nCopies(keys.size(), "?")) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("event_key"), r.getString("status")), keys.toArray()); + return Map.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java new file mode 100644 index 000000000..243a9ffb7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.*; + +/** Internal preparation for the handover transaction. No stock, fee or departure command is called. */ +@Service +public class FleetHandoverInspectionService { + private final FleetTripMapper trips; + private final FleetTripHistoryMapper history; + private final FleetTripDispatchMapper bindings; + private final FleetDispatchGateway dispatches; + private final ObjectMapper json; + + public FleetHandoverInspectionService(FleetTripMapper trips, FleetTripHistoryMapper history, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, ObjectMapper json) { + this.trips = trips; this.history = history; this.bindings = bindings; this.dispatches = dispatches; this.json = json; + } + + public record Prepared(long recordVersion, FleetHandoverPolicy.Inspection cargo, + List executionEvidence) { } + + /** Caller holds a transaction; command preparation locks trip -> customer dispatch -> outbound -> deliveries. */ + @Transactional(propagation = Propagation.MANDATORY) + public Prepared prepare(String tripNo, Instant handedOverAt, Load replacementCapacity, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null || trip.getCompletionJson() != null) + throw conflict("仅已发车且尚未结案的车次允许在途交接"); + var departures = history.selectList(new LambdaQueryWrapper() + .eq(FleetTripHistory::getTripId, trip.getId()).eq(FleetTripHistory::getAction, "DEPART")); + if (departures.size() != 1) throw conflict("车次缺少唯一原始发车快照"); + FleetTripModels.AuditSnapshot original; + try { original = json.readValue(departures.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class); } + catch (JsonProcessingException error) { throw new IllegalStateException("原始发车快照无法读取", error); } + if (original.trip() == null || !tripNo.equals(original.trip().tripNo()) + || !trip.getWarehouseId().toString().equals(original.trip().warehouseId()) + || !trip.getDepartedAt().toInstant(ZoneOffset.UTC).equals(original.trip().departedAt()) + || original.confirmedLoad() == null || original.confirmedLoad().cargo() == null + || original.confirmedLoad().pallets() == null || original.dispatches() == null) + throw conflict("原始发车快照不完整或与车次不一致"); + Map departedBindings = new TreeMap<>(); + for (var binding : original.dispatches()) { + if (departedBindings.putIfAbsent(binding.dispatchNo(), binding) != null) + throw conflict("原始发车快照包含重复客户排车"); + } + var current = bindings.listByTrip(trip.getId()); + if (current.size() != departedBindings.size()) throw conflict("车次成员与原始发车不一致"); + Set seen = new HashSet<>(); + for (var binding : current) { + var originalBinding = departedBindings.get(binding.getDispatchNo()); + if (!seen.add(binding.getDispatchNo()) || originalBinding == null + || !Objects.equals(originalBinding.customerId(), binding.getCustomerId()) + || !Objects.equals(originalBinding.packageCount(), binding.getPackageCount())) + throw conflict("客户排车归属或箱数与原始发车不一致"); + } + List evidence = new ArrayList<>(); + for (var binding : departedBindings.values()) { + var fact = dispatches.transitEvidence(binding.dispatchNo(), lock); + if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo()) + || !Objects.equals(binding.customerId(), fact.customerId()) + || !trip.getWarehouseId().toString().equals(fact.warehouseId()) + || binding.packageCount() != fact.shippedPackages()) + throw conflict("实际发运与车次客户、仓库或箱数不一致"); + evidence.add(fact); + } + var load = original.confirmedLoad(); + var cargo = load.cargo().stream().map(row -> new FleetHandoverPolicy.Cargo(row.customerId(), row.dispatchNo(), + row.dispatchItemId(), row.warehousePalletId(), row.destinationKey(), row.packages())).toList(); + var measured = load.pallets().stream().map(row -> new FleetHandoverPolicy.Pallet(row.warehousePalletId(), + new Load(row.palletSpaces(), row.weightKg(), row.volumeCbm()))).toList(); + try { + var inspection = FleetHandoverPolicy.inspect(original.trip().departedAt(), handedOverAt, Instant.now(), cargo, + evidence.stream().flatMap(row -> row.deliveries().stream()).map(FleetTransitEvidence.Delivery::cargo).toList(), + measured, replacementCapacity); + return new Prepared(trip.getRecordVersion(), inspection, List.copyOf(evidence)); + } catch (IllegalArgumentException invalid) { throw conflict(invalid.getMessage()); } + } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java new file mode 100644 index 000000000..649d8856b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.util.List; + +public final class FleetHandoverModels { + private FleetHandoverModels() { } + public record Selection(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, + @NotNull @Positive Long driverId, @NotNull Instant expectedDriverEndAt, @NotNull Instant handedOverAt) { } + public record Confirm(@NotNull @Valid Selection selection, @NotBlank @Pattern(regexp="[0-9a-f]{64}") String cargoHash, + @NotBlank @Size(max=500) String location, @Size(max=1000) String reason, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Release(@NotNull @Min(0) Long recordVersion, + @NotBlank @Pattern(regexp="VEHICLE|DRIVER") String resourceKind, + @NotNull Instant actualEndAt, @NotBlank @Size(max=500) String location, + @Pattern(regexp="IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Resource(String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone) { } + public record Preview(String tripNo, long recordVersion, String cargoHash, Resource previous, Resource replacement, + Load capacity, FleetHandoverPolicy.Inspection cargo, DriverQualificationPolicy.Qualification driverQualification) { } + public record DeliveryReference(String customerId, String deliveryNo) { } + public record Snapshot(Resource previous, Resource replacement, Instant handedOverAt, String location, String reason, + Instant expectedDriverEndAt, Load capacity, FleetHandoverPolicy.Inspection cargo, + List executionEvidence, List carriedDeliveries, String operatorId, Instant recordedAt) { } + public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String location, + String vehicleStatus, String operatorId, Instant recordedAt) { } + public record Response(String handoverNo, String tripNo, long recordVersion, Snapshot snapshot, + Released vehicleRelease, Released driverRelease) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + public long offset() { return (page - 1L) * size; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java new file mode 100644 index 000000000..2c6cdd9af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */ +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class FleetHandoverPdaQuery { + private final FleetTripDispatchMapper bindings; + private final FleetTripMapper trips; + private final FleetTripHistoryMapper history; + private final FleetDispatchGateway dispatches; + private final FleetHandoverRepository handovers; + private final ObjectMapper json; + public FleetHandoverPdaQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, FleetTripHistoryMapper history, + FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) { + this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json; + } + public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { } + public record Handover(String handoverNo, Instant handedOverAt, String location, + FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { } + public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion, + FleetTransitEvidence.Transport originalDeparture, List deliveries, Handover latestHandover) { } + + public Status status(long warehouseId, String dispatchNo) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) throw notFound(); + var trip = trips.findByNo(binding.getTripNo()); + // Check token-bound warehouse before reading any customer's transport or historical data. + if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound(); + if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); + var evidence = dispatches.transitEvidence(dispatchNo, false); + if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId()) + || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); + var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) + .eq(FleetTripHistory::getAction, "DEPART")); + if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); + try { + var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip(); + if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound(); + var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone()); + var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), + row.cargo().terminalAt(), row.transport())).toList(); + var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); + Handover handover = null; + if (latest != null) { + var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class); + handover = new Handover(latest.handoverNo(), snapshot.handedOverAt(), snapshot.location(), + transport(snapshot.previous()), transport(snapshot.replacement())); + } + return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover); + } catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); } + } + private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) { + return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone()); + } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前仓库没有该排车交接记录"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java new file mode 100644 index 000000000..8be21f895 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import java.time.Instant; +import java.util.*; + +/** Pure inspection of server-owned departure and delivery facts; never changes stock or fees. */ +public final class FleetHandoverPolicy { + private FleetHandoverPolicy() { } + + public record Cargo(String customerId, String dispatchNo, String itemId, String palletId, + String destinationKey, int packages) { } + /** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */ + public record Delivery(String customerId, String dispatchNo, String deliveryNo, + String destinationKey, int packages, Instant terminalAt) { } + public record Pallet(String palletId, Load measuredLoad) { } + public record Inspection(List remainingCargo, List completedDeliveries, + Set remainingPalletIds, Load occupied) { } + private record Destination(String customerId, String dispatchNo, String key) { } + private record Item(String customerId, String dispatchNo, String itemId) { } + private record DeliveryKey(String customerId, String deliveryNo) { } + + /** + * Caller locks trip, dispatches and deliveries and derives terminalAt from trusted execution facts. + * An unresolved refusal has no terminalAt and remains aboard. A signed delivery later returned + * uses its original signature. This policy does not invent per-box partial-signature facts. + */ + public static Inspection inspect(Instant departedAt, Instant handedOverAt, Instant now, + List departureCargo, List deliveries, + List departureMeasurements, Load replacementCapacity) { + if (departedAt == null || handedOverAt == null || now == null + || handedOverAt.isBefore(departedAt) || handedOverAt.isAfter(now)) + throw invalid("交接时间须在实际发车之后且不晚于当前时间"); + if (departureCargo == null || departureCargo.isEmpty() || deliveries == null || deliveries.isEmpty()) + throw invalid("缺少原始发运或派送依据"); + Map shipped = new HashMap<>(); + Map palletDestinations = new HashMap<>(); + Set itemKeys = new HashSet<>(); + for (Cargo cargo : departureCargo) { + if (cargo == null || blank(cargo.customerId()) || blank(cargo.dispatchNo()) || blank(cargo.itemId()) + || blank(cargo.palletId()) || blank(cargo.destinationKey()) || cargo.packages() <= 0) + throw invalid("原始发运缺少客户、排车、实托、目的地或箱数"); + if (!itemKeys.add(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()))) + throw invalid("原始发运货物重复"); + shipped.merge(destination(cargo), cargo.packages(), Math::addExact); + String previous = palletDestinations.putIfAbsent(cargo.palletId(), cargo.destinationKey()); + if (previous != null && !previous.equals(cargo.destinationKey())) + throw invalid("同一实托包含不同卸货目的地,请先核对原始装载"); + } + Map byDestination = new HashMap<>(); + Set deliveryKeys = new HashSet<>(); + List completed = new ArrayList<>(); + for (Delivery delivery : deliveries) { + if (delivery == null || blank(delivery.customerId()) || blank(delivery.dispatchNo()) + || blank(delivery.deliveryNo()) || blank(delivery.destinationKey()) || delivery.packages() <= 0) + throw invalid("派送事实缺少业务归属或箱数"); + var key = new Destination(delivery.customerId(), delivery.dispatchNo(), delivery.destinationKey()); + if (!deliveryKeys.add(new DeliveryKey(delivery.customerId(), delivery.deliveryNo())) + || byDestination.putIfAbsent(key, delivery) != null) + throw invalid("派送事实重复或目的地拆分尚未核平"); + if (!Objects.equals(shipped.get(key), delivery.packages())) + throw invalid("派送客户、排车、目的地或箱数与原始发运不一致"); + if (delivery.terminalAt() != null) { + if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt)) + throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对"); + completed.add(delivery); + } + } + if (!byDestination.keySet().equals(shipped.keySet())) throw invalid("派送事实未覆盖全部原始发运货物"); + Map measurements = new HashMap<>(); + if (departureMeasurements == null) throw invalid("缺少原始实测装载"); + for (Pallet pallet : departureMeasurements) { + if (pallet == null || blank(pallet.palletId()) || !positive(pallet.measuredLoad()) + || measurements.putIfAbsent(pallet.palletId(), pallet.measuredLoad()) != null) + throw invalid("原始实托测量缺失或重复"); + } + if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致"); + List remaining = departureCargo.stream() + .filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList(); + if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理"); + Set palletIds = new TreeSet<>(); + remaining.forEach(cargo -> palletIds.add(cargo.palletId())); + Load occupied = new Load(0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO); + // Keep the measured whole-pallet load when only one customer's portion has been signed. + // It is unsafe to infer residual weight/volume from package-count ratios. + for (String palletId : palletIds) occupied = occupied.plus(measurements.get(palletId)); + if (!positive(replacementCapacity)) throw invalid("请确认接替车辆托位、载重和容积"); + if (occupied.palletSpaces() > replacementCapacity.palletSpaces() + || occupied.weightKg().compareTo(replacementCapacity.weightKg()) > 0 + || occupied.volumeCbm().compareTo(replacementCapacity.volumeCbm()) > 0) + throw invalid("未完成货物超过接替车辆容量"); + return new Inspection(List.copyOf(remaining), List.copyOf(completed), + Collections.unmodifiableSet(palletIds), occupied); + } + + private static Destination destination(Cargo cargo) { + return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey()); + } + private static boolean positive(Load load) { + return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0; + } + private static boolean blank(String text) { return text == null || text.isBlank(); } + private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java new file mode 100644 index 000000000..ff2429569 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.fleet.exception; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +/** All mutations are serialized by the owning trip row lock. Stored snapshots are immutable. */ +@Repository +public class FleetHandoverRepository { + record Row(long id, long tripId, long tripVersion, String tripNo, String handoverNo, String requestKey, String requestHash, + long version, Instant handedOverAt, String snapshot, String vehicleRelease, String driverRelease) { } + private static final RowMapper ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getLong("trip_version"), + r.getString("trip_no"), r.getString("handover_no"), r.getString("request_key"), r.getString("request_hash"), + r.getLong("record_version"), r.getTimestamp("handed_over_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + r.getString("snapshot_json"), r.getString("vehicle_release_json"), r.getString("driver_release_json")); + private final JdbcTemplate jdbc; + public FleetHandoverRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + Row created(long tripId, String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND request_key=?", ROW, tripId, key).stream().findFirst().orElse(null); + } + Row find(long tripId, String number) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND handover_no=?", ROW, tripId, number).stream().findFirst().orElse(null); + } + Row latest(long tripId) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT 1", ROW, tripId).stream().findFirst().orElse(null); + } + Row latestForCargo(long tripId, String customerDispatchJson) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND JSON_CONTAINS(snapshot_json,?,'$.cargo.remainingCargo')=1 ORDER BY trip_version DESC LIMIT 1", + ROW, tripId, customerDispatchJson).stream().findFirst().orElse(null); + } + long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_handover WHERE trip_id=?", Long.class, tripId); } + List page(long tripId, FleetHandoverModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset()); + } + int insert(Row row, String operator, Instant now) { + return jdbc.update("INSERT INTO ll_fleet_trip_handover(id,trip_id,trip_version,trip_no,handover_no,request_key,request_hash,record_version,handed_over_at,snapshot_json,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + row.id(), row.tripId(), row.tripVersion(), row.tripNo(), row.handoverNo(), row.requestKey(), row.requestHash(), row.version(), + LocalDateTime.ofInstant(row.handedOverAt(), ZoneOffset.UTC), row.snapshot(), operator, LocalDateTime.ofInstant(now, ZoneOffset.UTC)); + } + int release(Row row, boolean vehicle, String result) { + String column = vehicle ? "vehicle_release_json" : "driver_release_json"; + return jdbc.update("UPDATE ll_fleet_trip_handover SET " + column + "=?,record_version=record_version+1 WHERE id=? AND record_version=? AND " + column + " IS NULL", + result, row.id(), row.version()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java new file mode 100644 index 000000000..42b59463f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java @@ -0,0 +1,208 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.driver.DriverModels; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*; + +@Service +@Transactional(isolation = Isolation.READ_COMMITTED) +public class FleetHandoverService { + private final FleetTripMapper trips; + private final FleetResourceMapper resources; + private final FleetTripService history; + private final FleetDispatchGateway dispatches; + private final VehicleRepository vehicles; + private final VehicleApplicationService vehicleService; + private final DriverService drivers; + private final FleetHandoverInspectionService inspection; + private final FleetHandoverRepository repository; + private final ObjectMapper json; + + public FleetHandoverService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripService history, + FleetDispatchGateway dispatches, VehicleRepository vehicles, VehicleApplicationService vehicleService, + DriverService drivers, FleetHandoverInspectionService inspection, FleetHandoverRepository repository, ObjectMapper json) { + this.trips = trips; this.resources = resources; this.history = history; this.dispatches = dispatches; + this.vehicles = vehicles; this.vehicleService = vehicleService; this.drivers = drivers; + this.inspection = inspection; this.repository = repository; this.json = json; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public PageResult page(String tripNo, Query query) { + var trip = required(tripNo, false); + long count = repository.count(trip.getId()); + return new PageResult<>(repository.page(trip.getId(), query).stream().map(this::response).toList(), + query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + + public Preview preview(String tripNo, Selection selection) { + var trip = required(tripNo, true); + return prepare(trip, selection).preview(); + } + + public Response confirm(String tripNo, String operator, Confirm request) { + var trip = required(tripNo, true); + String requestHash = hash(request); + var previous = repository.created(trip.getId(), request.idempotencyKey()); + if (previous != null) { + if (!previous.requestHash().equals(requestHash)) throw conflict("交接请求标识已用于不同内容"); + return response(previous); + } + var prepared = prepare(trip, request.selection()); + if (!prepared.preview().cargoHash().equals(request.cargoHash())) throw conflict("交接货物、资源或派送状态已变化,请重新核对"); + if (request.location() == null || request.location().isBlank()) throw bad("请填写实际交接地点"); + reserve("VEHICLE", request.selection().vehicleId(), trip); + reserve("DRIVER", request.selection().driverId(), trip); + var replacement = prepared.preview().replacement(); + for (var evidence : prepared.evidence()) dispatches.handover(evidence, + new FleetDispatchGateway.Assignment(replacement.vehiclePlate(), replacement.driverName(), replacement.driverPhone())); + Instant recordedAt = Instant.now(); + String reason = request.reason() == null || request.reason().isBlank() ? "在途车辆司机交接" : request.reason().trim(); + var snapshot = new Snapshot(prepared.preview().previous(), replacement, request.selection().handedOverAt(), + request.location().trim(), reason, request.selection().expectedDriverEndAt(), prepared.preview().capacity(), + prepared.preview().cargo(), prepared.evidence(), prepared.evidence().stream().flatMap(item -> item.deliveries().stream()) + .filter(item -> item.cargo().terminalAt() == null).map(item -> new DeliveryReference(item.cargo().customerId(), item.cargo().deliveryNo())).toList(), operator, recordedAt); + var row = new FleetHandoverRepository.Row(IdWorker.getId(), trip.getId(), request.selection().recordVersion() + 1, tripNo, + "HO-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT), + request.idempotencyKey(), requestHash, 0, request.selection().handedOverAt(), write(snapshot), null, null); + if (repository.insert(row, operator, recordedAt) != 1) throw conflict("交接记录保存失败"); + trip.setVehicleId(request.selection().vehicleId()); trip.setVehiclePlate(replacement.vehiclePlate()); + trip.setDriverId(request.selection().driverId()); trip.setDriverName(replacement.driverName()); trip.setDriverPhone(replacement.driverPhone()); + trip.setMaxPalletSpaces(prepared.preview().capacity().palletSpaces()); trip.setMaxLoadKg(prepared.preview().capacity().weightKg()); + trip.setMaxVolumeCbm(prepared.preview().capacity().volumeCbm()); + trip.setExpectedDriverEndAt(LocalDateTime.ofInstant(request.selection().expectedDriverEndAt(), ZoneOffset.UTC)); + // Old resources remain reserved until their own actual task ends. DEPART and load_json are never rewritten. + history.recordExecution(trip, request.selection().recordVersion(), "HANDOVER", reason, operator); + return response(row); + } + + public Response release(String tripNo, String handoverNo, String operator, Release request) { + var trip = required(tripNo, true); + var row = repository.find(trip.getId(), handoverNo); + if (row == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "交接记录不存在"); + if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务"); + boolean vehicle = "VEHICLE".equals(request.resourceKind()); + String requestHash = hash(request), previous = vehicle ? row.vehicleRelease() : row.driverRelease(); + if (previous != null) { + var released = read(previous, Released.class); + if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(requestHash)) + throw conflict("原资源任务已结束,请核对原请求"); + return response(row); + } + if (row.version() != request.recordVersion()) throw conflict("交接记录已变化,请刷新"); + var snapshot = read(row.snapshot(), Snapshot.class); + String oldId = vehicle ? snapshot.previous().vehicleId() : snapshot.previous().driverId(); + String nextId = vehicle ? snapshot.replacement().vehicleId() : snapshot.replacement().driverId(); + Long currentId = vehicle ? trip.getVehicleId() : trip.getDriverId(); + if (oldId.equals(nextId) || oldId.equals(Objects.toString(currentId, null))) + throw conflict("该资源仍承担当前运输任务,不能通过旧交接结束"); + if (request.actualEndAt() == null || request.actualEndAt().isBefore(snapshot.handedOverAt()) + || request.actualEndAt().isAfter(Instant.now()) || request.location() == null || request.location().isBlank()) + throw bad("请填写不早于交接且不晚于当前的实际结束时间和地点"); + if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) + throw bad("请确认原车辆结束后的实际状态"); + if (!vehicle && request.vehicleStatus() != null) throw bad("司机任务结束不能修改车辆状态"); + long resourceId = Long.parseLong(oldId); + if (vehicle) { + var oldVehicle = vehicles.lockById(resourceId).orElseThrow(() -> conflict("原车辆档案不存在")); + releaseResource(request.resourceKind(), resourceId, trip.getId()); + vehicleService.changeStatus(resourceId, operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), oldVehicle.recordVersion())); + } else releaseResource(request.resourceKind(), resourceId, trip.getId()); + var released = new Released(request.idempotencyKey(), requestHash, request.actualEndAt(), request.location().trim(), + request.vehicleStatus(), operator, Instant.now()); + if (repository.release(row, vehicle, write(released)) != 1) throw conflict("原资源结束记录已变化"); + history.recordExecution(trip, trip.getRecordVersion(), vehicle ? "HANDOVER_RELEASE_VEHICLE" : "HANDOVER_RELEASE_DRIVER", + "结束交接前资源任务", operator); + return response(repository.find(trip.getId(), handoverNo)); + } + + private record Prepared(Preview preview, List evidence) { } + private record Basis(Selection selection, Resource previous, Resource replacement, Load capacity, + com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Qualification qualification, + FleetHandoverInspectionService.Prepared inspection) { } + private Prepared prepare(FleetTripEntity trip, Selection selection) { + if (selection.recordVersion() == null || trip.getRecordVersion().longValue() != selection.recordVersion()) + throw conflict("车次已变化,请刷新后重新核对"); + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getVehicleId() == null || trip.getDriverId() == null) + throw conflict("仅已发车且资源完整的车次可交接"); + if (Objects.equals(selection.vehicleId(), trip.getVehicleId()) && Objects.equals(selection.driverId(), trip.getDriverId())) + throw bad("请选择需要接替的车辆或司机"); + if (selection.expectedDriverEndAt() == null || !selection.expectedDriverEndAt().isAfter(Instant.now()) + || selection.handedOverAt() == null || !selection.expectedDriverEndAt().isAfter(selection.handedOverAt())) + throw bad("司机预计任务结束须晚于交接和当前时间"); + var latest = repository.latest(trip.getId()); + if (latest != null && selection.handedOverAt().isBefore(read(latest.snapshot(), Snapshot.class).handedOverAt())) + throw bad("交接时间不能早于上次交接"); + requireOwned("VEHICLE", trip.getVehicleId(), trip.getId()); requireOwned("DRIVER", trip.getDriverId(), trip.getId()); + var vehicle = vehicles.lockById(selection.vehicleId()).orElseThrow(() -> bad("接替车辆不存在")); + if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆"); + if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null) + throw bad("请先确认接替车辆实际容量"); + DriverModels.Response driver = drivers.requireDispatchable(selection.driverId(), selection.expectedDriverEndAt()); + if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机"); + requireAvailable("VEHICLE", selection.vehicleId(), trip.getVehicleId(), trip.getId()); + requireAvailable("DRIVER", selection.driverId(), trip.getDriverId(), trip.getId()); + var capacity = new Load(vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm()); + var checked = inspection.prepare(trip.getTripNo(), selection.handedOverAt(), capacity, true); + var previous = new Resource(trip.getVehicleId().toString(), trip.getVehiclePlate(), trip.getDriverId().toString(), trip.getDriverName(), trip.getDriverPhone()); + var replacement = new Resource(Long.toString(vehicle.id()), vehicle.plateNumber(), driver.id(), driver.details().name(), driver.details().phone()); + String cargoHash = hash(new Basis(selection, previous, replacement, capacity, driver.qualification(), checked)); + return new Prepared(new Preview(trip.getTripNo(), trip.getRecordVersion(), cargoHash, previous, replacement, + capacity, checked.cargo(), driver.qualification()), checked.executionEvidence()); + } + private void requireOwned(String kind, long id, long tripId) { + var owner = resources.find(kind, id); + if (owner == null || owner.tripId() != tripId) throw conflict("当前资源占用与车次不一致"); + } + private void requireAvailable(String kind, long id, long currentId, long tripId) { + var owner = resources.find(kind, id); + if (owner != null && (id != currentId || owner.tripId() != tripId)) + throw conflict("接替资源仍有未结束任务,请先核对实际任务结束"); + } + private void reserve(String kind, long id, FleetTripEntity trip) { + long current = "VEHICLE".equals(kind) ? trip.getVehicleId() : trip.getDriverId(); + if (id == current) { requireOwned(kind, id, trip.getId()); return; } + try { if (resources.reserve(kind, id, trip.getId(), trip.getTripNo()) != 1) throw conflict("接替资源占用保存失败"); } + catch (DuplicateKeyException occupied) { throw conflict("接替资源已被占用,请重新核对"); } + } + private void releaseResource(String kind, long id, long tripId) { + if (resources.release(kind, id, tripId) != 1) throw conflict("原资源占用不一致,未结束任务"); + } + private FleetTripEntity required(String tripNo, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private Response response(FleetHandoverRepository.Row row) { + return new Response(row.handoverNo(), row.tripNo(), row.version(), read(row.snapshot(), Snapshot.class), + row.vehicleRelease() == null ? null : read(row.vehicleRelease(), Released.class), + row.driverRelease() == null ? null : read(row.driverRelease(), Released.class)); + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java new file mode 100644 index 000000000..de5162f38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.fleet.exception; + +import java.util.List; + +/** Internal port response. Identity always comes from the original customer dispatch. */ +public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId, + int shippedPackages, List deliveries) { + public record Transport(String vehiclePlate, String driverName, String driverPhone) { } + public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java new file mode 100644 index 000000000..5a14d5c46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers") +public class PlatformFleetHandoverController { + private final FleetHandoverService service; + public PlatformFleetHandoverController(FleetHandoverService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> page(@PathVariable String tripNo, @Valid FleetHandoverModels.Query query) { + return ApiResponse.success(service.page(tripNo, query)); + } + @PostMapping("/preview") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse preview(@PathVariable String tripNo, @Valid @RequestBody FleetHandoverModels.Selection request) { + return ApiResponse.success(service.preview(tripNo, request)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:handover')") + public ApiResponse confirm(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody FleetHandoverModels.Confirm request) { return ApiResponse.success(service.confirm(tripNo, operator(user), request)); } + @PostMapping("/{handoverNo}/resources/release") + @PreAuthorize("hasAuthority('platform:fleet:trip:release')") + public ApiResponse release(@PathVariable String tripNo, @PathVariable String handoverNo, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetHandoverModels.Release request) { + return ApiResponse.success(service.release(tripNo, handoverNo, operator(user), request)); + } + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java new file mode 100644 index 000000000..389f2c663 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records") +public class PlatformVehicleCareFinanceController { + private final VehicleCareFinanceService service; + public PlatformVehicleCareFinanceController(VehicleCareFinanceService service) { this.service = service; } + @PostMapping("/finance-status") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ApiResponse> statuses(@PathVariable long vehicleId, @Valid @RequestBody VehicleCareFinanceModels.Query request) { + return ApiResponse.success(service.statuses(vehicleId, request.recordNos())); + } + @PostMapping("/{recordNo}/finance") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-finance-submit')") + public ApiResponse submit(@PathVariable long vehicleId, @PathVariable String recordNo, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody VehicleCareFinanceModels.Submit request) { + return ApiResponse.success(service.submit(vehicleId, recordNo, user.userId().toString(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java new file mode 100644 index 000000000..a303b06ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="finance-service", contextId="vehicleCareFinance", url="${baiyue.clients.finance.url:}") +public interface VehicleCareFinanceClient { + @PostMapping("/internal/finance/v1/vehicle-costs/sources/query") + ApiResponse> query(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody VehicleCareFinanceModels.SourceQuery request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java new file mode 100644 index 000000000..b1684c94e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.event.EventEnvelope; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** The fee slice owns currency conversion and the shared reliable outbox. */ +public interface VehicleCareFinanceGateway { + record Conversion(String originalCurrency, BigDecimal originalAmount, BigDecimal exchangeRate, + String exchangeRateMonth, BigDecimal settlementAmount) { } + Conversion convert(String currency, BigDecimal amount, LocalDate businessDate); + /** Must join the caller's transaction; failure must roll back the fleet source mapping too. */ + void publish(EventEnvelope event); + Map deliveryStates(List eventKeys); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java new file mode 100644 index 000000000..d5930ee6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class VehicleCareFinanceModels { + private VehicleCareFinanceModels() { } + public record Submit(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long supplierId, + @NotNull LocalDate businessDate, @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Query(@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> recordNos) { } + public record Source(String sourceType, String sourceNo, long sourceVersion, String vehicleId, String vehiclePlate, + String tripNo, long supplierId, String feeName, LocalDate businessDate, String originalCurrency, BigDecimal originalAmount, + BigDecimal exchangeRate, String exchangeRateMonth, BigDecimal amount, String remark) { } + public record SourceKey(String sourceType, String sourceNo) { } + public record SourceQuery(List sources) { } + public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, String status, + BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { } + public record Status(String recordNo, String status, Long sourceVersion, Long submittedRecordVersion, Cost finance) { } + public record Submitted(String recordNo, long sourceVersion, long submittedRecordVersion, boolean replayed, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java new file mode 100644 index 000000000..76015dacb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java @@ -0,0 +1,149 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +@Service +public class VehicleCareFinanceService { + private static final String EVENT_TYPE = EventTypes.COMPANY_VEHICLE_COST_SUBMITTED; + private final VehicleCareMapper records; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final VehicleCareFinanceGateway accounting; + private final VehicleCareFinanceClient finance; + private final VehicleCareSupplierClient suppliers; + private final String token; + record Submission(long recordId, long sourceVersion, long recordVersion, String eventId, String requestHash, String payload) { } + public VehicleCareFinanceService(VehicleCareMapper records, JdbcTemplate jdbc, ObjectMapper json, VehicleCareFinanceGateway accounting, + VehicleCareFinanceClient finance, VehicleCareSupplierClient suppliers, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.records = records; this.jdbc = jdbc; this.json = json; this.accounting = accounting; + this.finance = finance; this.suppliers = suppliers; this.token = token; + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCareFinanceModels.Submitted submit(long vehicleId, String number, String operator, VehicleCareFinanceModels.Submit command) { + var row = records.lock(vehicleId, number); requireCostRecord(row); + String hash = hash(command); + var prior = jdbc.query("SELECT * FROM ll_fleet_finance_source WHERE record_id=? AND operator_id=? AND request_key=?", + (r, i) -> submission(r), row.getId(), operator, command.idempotencyKey()); + if (!prior.isEmpty()) { + if (!hash.equals(prior.get(0).requestHash())) throw conflict("财务提交标识已用于其他内容"); + return new VehicleCareFinanceModels.Submitted(number, prior.get(0).sourceVersion(), prior.get(0).recordVersion(), true, "PENDING_FINANCE"); + } + if (!row.getRecordVersion().equals(command.recordVersion())) throw conflict("运维记录已变化,请重新核对后提交"); + var latest = latest(row.getId()); + var current = queryFinance(List.of(row)).get(number); // Fail closed if Finance cannot establish current facts. + if (latest != null) { + if (current == null || current.source().sourceVersion() != latest.sourceVersion()) throw conflict("上次提交尚未完成财务接收,请稍后核对"); + if (!"VOIDED".equals(current.status())) throw conflict("费用已提交,重新登记须先在财务作废;已付款费用不可改写"); + } else if (current != null) throw conflict("财务来源已有记录但本地提交关系不一致,请核对"); + var details = draft(row); var incident = details.incident(); + if (incident == null || incident.reportedCost() == null || incident.reportedCost().signum() <= 0) throw bad("请先保存正数申报费用;未填写或明确零费用不生成财务应付"); + requireSupplier(command.supplierId()); + var converted = accounting.convert(incident.currency(), incident.reportedCost(), command.businessDate()); + if (converted.settlementAmount().signum() <= 0 || converted.settlementAmount().precision() - converted.settlementAmount().scale() > 15) + throw bad("折算金额必须为正数且在财务支持范围内"); + var source = new VehicleCareFinanceModels.Source(row.getRecordType(), row.getRecordNo(), latest == null ? 1 : latest.sourceVersion() + 1, + row.getVehicleId().toString(), row.getVehiclePlate(), null, command.supplierId(), incident.item(), command.businessDate(), + converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount(), details.remark()); + if (source.feeName() == null || source.feeName().length() > 200) throw bad("提交财务的维修或救援项目须在200字以内"); + var payload = json.valueToTree(source); String eventId = UUID.randomUUID().toString(); + String eventKey = EVENT_TYPE + ':' + row.getRecordType() + ':' + number + ':' + source.sourceVersion(); + var envelope = new EventEnvelope(eventId, EVENT_TYPE, 1, Instant.now(), "0", "COMPANY_VEHICLE_COST", + row.getRecordType() + ':' + number, source.sourceVersion(), null, UUID.randomUUID().toString(), eventKey, null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + jdbc.update("INSERT INTO ll_fleet_finance_source(id,record_id,source_version,record_version,operator_id,request_key,request_hash,event_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(), row.getId(), source.sourceVersion(), row.getRecordVersion(), operator, command.idempotencyKey(), hash, eventId, write(source), LocalDateTime.now(ZoneOffset.UTC)); + accounting.publish(envelope); + return new VehicleCareFinanceModels.Submitted(number, source.sourceVersion(), row.getRecordVersion(), false, "PENDING_FINANCE"); + } + public List statuses(long vehicleId, List numbers) { + if (numbers == null || numbers.isEmpty() || numbers.size() > 100 || numbers.stream().anyMatch(n -> n == null || n.isBlank())) throw bad("财务状态查询须提供1至100个有效记录号"); + var recordsByNo = records.findFinanceRecords(vehicleId, List.copyOf(new LinkedHashSet<>(numbers))); + if (recordsByNo.size() != new HashSet<>(numbers).size()) throw missing(); + var latestById = latest(recordsByNo.stream().map(VehicleCareEntity::getId).toList()); + var remote = latestById.isEmpty() ? Map.of() : queryFinance(recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())).toList()); + var deliveryKeys = recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())) + .map(r -> EVENT_TYPE + ':' + r.getRecordType() + ':' + r.getRecordNo() + ':' + latestById.get(r.getId()).sourceVersion()).toList(); + var deliveries = accounting.deliveryStates(deliveryKeys); + return recordsByNo.stream().map(row -> { + var local = latestById.get(row.getId()); if (local == null) return new VehicleCareFinanceModels.Status(row.getRecordNo(), "UNLINKED", null, null, null); + var cost = remote.get(row.getRecordNo()); + if (cost == null || cost.source().sourceVersion() < local.sourceVersion()) { + var delivery = deliveries.get(EVENT_TYPE + ':' + row.getRecordType() + ':' + row.getRecordNo() + ':' + local.sourceVersion()); + if (delivery == null) throw unavailable("财务投递关系缺失,状态尚未确认"); + return new VehicleCareFinanceModels.Status(row.getRecordNo(), "DEAD".equals(delivery) ? "FINANCE_ATTENTION" : "PENDING_FINANCE", local.sourceVersion(), local.recordVersion(), null); + } + if (cost.source().sourceVersion() != local.sourceVersion()) throw conflict("财务来源版本与本地提交不一致"); + if (!hash(cost.source()).equals(hash(read(local.payload(), VehicleCareFinanceModels.Source.class)))) throw conflict("财务来源内容与本地提交快照不一致"); + return new VehicleCareFinanceModels.Status(row.getRecordNo(), cost.status(), local.sourceVersion(), local.recordVersion(), cost); + }).toList(); + } + /** Called under the existing record lock; ordinary processing updates do not depend on Finance. */ + public void guardFinancialEdit(VehicleCareEntity row, VehicleCareModels.Draft next) { + if (!Set.of("REPAIR", "RESCUE").contains(row.getRecordType())) return; + var before = draft(row); + if (Objects.equals(before.incident().item(), next.incident().item()) + && Objects.equals(before.incident().currency(), next.incident().currency()) + && equalAmount(before.incident().reportedCost(), next.incident().reportedCost())) return; + var latest = latest(row.getId()); if (latest == null) return; + var remote = queryFinance(List.of(row)).get(row.getRecordNo()); + if (remote == null || remote.source().sourceVersion() != latest.sourceVersion()) throw conflict("财务接收状态未确认,暂不能更改费用字段"); + if (!"VOIDED".equals(remote.status())) throw conflict("已提交费用须先在财务作废,已付款费用禁止改写"); + } + private Map queryFinance(List rows) { + requireToken(); + try { + var keys = rows.stream().map(r -> new VehicleCareFinanceModels.SourceKey(r.getRecordType(), r.getRecordNo())).toList(); + var response = finance.query(token, new VehicleCareFinanceModels.SourceQuery(keys)); + if (response == null || !response.successful() || response.data() == null) throw unavailable("财务状态读取失败,未确认入账或付款"); + var result = new HashMap(); + for (var cost : response.data()) { + if (cost == null || cost.source() == null || !"PLATFORM_DEFAULT".equals(cost.subjectCode()) || !"CNY".equals(cost.currency()) + || !Set.of("PENDING","APPROVED","PARTIALLY_PAID","PAID","VOIDED").contains(Objects.toString(cost.status(), "")) + || !keys.contains(new VehicleCareFinanceModels.SourceKey(cost.source().sourceType(), cost.source().sourceNo())) + || rows.stream().noneMatch(r -> r.getRecordNo().equals(cost.source().sourceNo()) && r.getVehicleId().toString().equals(cost.source().vehicleId())) + || cost.paidAmount() == null || cost.paidAmount().signum() < 0 + || result.put(cost.source().sourceNo(), cost) != null) throw unavailable("财务返回的来源归属或状态无效"); + } + return result; + } catch (feign.FeignException error) { throw unavailable("Finance暂不可用,请稍后核对"); } + } + private void requireSupplier(long id) { + requireToken(); + try { var r = suppliers.get(token, id, true); + if (r == null || !r.successful() || r.data() == null || r.data().supplierId() != id || !"ACTIVE".equals(r.data().status())) throw bad("供应商不存在或未启用"); + } catch (feign.FeignException error) { throw unavailable("供应商主数据暂不可用"); } + } + private Submission latest(long recordId) { return latest(List.of(recordId)).get(recordId); } + private Map latest(List ids) { + if (ids.isEmpty()) return Map.of(); + var rows = jdbc.query("SELECT s.* FROM ll_fleet_finance_source s JOIN (SELECT record_id,MAX(source_version) source_version FROM ll_fleet_finance_source WHERE record_id IN (" + placeholders(ids.size()) + ") GROUP BY record_id) v ON v.record_id=s.record_id AND v.source_version=s.source_version", + (r, i) -> submission(r), ids.toArray()); + var map = new HashMap(); rows.forEach(r -> map.put(r.recordId(), r)); return map; + } + private static Submission submission(java.sql.ResultSet r) throws java.sql.SQLException { return new Submission(r.getLong("record_id"), r.getLong("source_version"), r.getLong("record_version"), r.getString("event_id"), r.getString("request_hash"), r.getString("payload_json")); } + private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); } + private VehicleCareModels.Draft draft(VehicleCareEntity row) { return read(row.getDetailsJson(), VehicleCareModels.Draft.class); } + private static boolean equalAmount(BigDecimal a, BigDecimal b) { return a == null ? b == null : b != null && a.compareTo(b) == 0; } + private String hash(Object value) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(value))); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法保存", e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法读取", e); } } + private static void requireCostRecord(VehicleCareEntity row) { if (row == null || !Set.of("REPAIR","RESCUE").contains(row.getRecordType())) throw missing(); } + private void requireToken() { if (token == null || token.isBlank()) throw unavailable("内部服务身份未配置"); } + private static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, text); } + private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); } + private static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, text); } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前车辆维修救援记录不存在"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java index d7c9e1b6b..57e08c11a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java @@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.*; @Mapper public interface VehicleCareMapper extends BaseMapper { + @Select({""}) + java.util.List findFinanceRecords(@Param("vehicleId") long vehicleId, @Param("numbers") java.util.List numbers); @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}") VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo); @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java index 20968c2ab..93ec5c040 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java @@ -27,9 +27,10 @@ public class VehicleCareService { private final VehicleRepository vehicles; private final VehicleCareFileGateway files; private final ObjectMapper json; + private final VehicleCareFinanceService finance; public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories, - VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) { - this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; + VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json, VehicleCareFinanceService finance) { + this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; this.finance = finance; } public PageResult page(long vehicleId, VehicleCareModels.Query query) { vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing); @@ -65,6 +66,7 @@ public class VehicleCareService { public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) { var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details()); if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型"); + finance.guardFinancialEdit(row, request.details()); if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因"); row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); persist(row, request.recordVersion(), "UPDATE", request.reason(), operator); @@ -121,7 +123,7 @@ public class VehicleCareService { return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(), read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(), row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC), - Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null); + null); // Current financial facts are queried in a separate bounded batch, never frozen into operational history. } private VehicleCareEntity required(long vehicleId, String number, boolean lock) { var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java new file mode 100644 index 000000000..c6c7f7734 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="warehouse-service", contextId="vehicleCareSupplier", url="${baiyue.clients.warehouse.url:}") +public interface VehicleCareSupplierClient { + @GetMapping("/internal/warehouse/v1/suppliers/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId, + @RequestParam boolean requireActive); + record Supplier(long supplierId, String name, String category, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java new file mode 100644 index 000000000..174978e3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class FleetQuoteDirectory { + private final FleetCustomerDirectory customers; + private final FleetQuoteSupplierClient suppliers; + private final String token; + public FleetQuoteDirectory(FleetCustomerDirectory customers,FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token}") String token) { + this.customers=customers;this.suppliers=suppliers;this.token=token; + } + public String name(String kind,long id) { + if("SALE".equals(kind)) return customers.required(id).name(); + try { + var result=suppliers.get(token,id,true,"TRUCKING"); + if(result==null || !result.successful() || result.data()==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用"); + var row=result.data(); + if(row.supplierId()!=id || !"ACTIVE".equals(row.status()) || !"TRUCKING".equals(row.category()) || !"OVERSEAS".equals(row.truckingScope())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效的海外车队供应商"); + return row.name(); + } catch(ResponseStatusException e) { throw e; } + catch(RuntimeException e) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用,请重试"); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java new file mode 100644 index 000000000..222ae6e60 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.quote; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; + +public final class FleetQuoteModels { + private FleetQuoteModels() { } + public record Draft(@NotBlank @Pattern(regexp="PURCHASE|SALE") String kind, @NotNull @Positive Long partyId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotNull @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal distanceMiles, + @NotBlank @Pattern(regexp="USD|CNY") String currency, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal selfPickupPrice, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal fullDeliveryPrice, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal looseDeliveryPrice, + @NotNull LocalDate validFrom, @NotNull LocalDate validTo, + @Size(max=1000) String remark) { } + public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details) { } + public record Status(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp="ACTIVE|INACTIVE") String status) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @Pattern(regexp="PURCHASE|SALE") String kind, @Size(max=128) String keyword, + @Pattern(regexp="ACTIVE|INACTIVE") String status, LocalDate effectiveOn) { + public Query { page=page==null?1:page; size=size==null?20:size; } + } + public record Quote(String quoteNo, Draft details, String partyName, String status, long recordVersion, Instant updatedAt) { } + public record History(long recordVersion, String action, Instant occurredAt, Quote snapshot) { } + public record Import(@NotBlank @Size(min=8,max=100) String idempotencyKey, @NotEmpty @Size(max=200) List<@Valid @NotNull Draft> rows) { } + public record ImportResult(int importedRows, List records) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java new file mode 100644 index 000000000..181ada62e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.quote; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.*; + +@Repository +public class FleetQuoteRepository { + record Row(long id,String quoteNo,String details,String partyName,String status,long version,String createHash,LocalDateTime updatedAt) { } + record Audit(long version,String action,LocalDateTime occurredAt,String snapshot) { } + private final JdbcTemplate jdbc; + public FleetQuoteRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private static final RowMapper MAPPER=(r,i)->new Row(r.getLong("id"),r.getString("quote_no"),r.getString("details_json"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("create_hash"),r.getTimestamp("updated_at").toLocalDateTime()); + Row find(String no,boolean lock) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE quote_no=?"+(lock?" FOR UPDATE":""),MAPPER,no).stream().findFirst().orElse(null); } + Row replay(String operator,String key) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE created_by=? AND create_key=?",MAPPER,operator,key).stream().findFirst().orElse(null); } + String creationSnapshot(long id) { return jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_quote_history WHERE quote_id=? AND record_version=0",String.class,id); } + List list(FleetQuoteModels.Query q) { + StringBuilder sql=new StringBuilder("SELECT * FROM ll_fleet_quote WHERE status<>'DELETED'"); var args=new ArrayList(); + if(q.kind()!=null&&!q.kind().isBlank()) {sql.append(" AND kind=?");args.add(q.kind());} + if(q.status()!=null&&!q.status().isBlank()) {sql.append(" AND status=?");args.add(q.status());} + if(q.effectiveOn()!=null) {sql.append(" AND valid_from<=? AND valid_to>=? AND status='ACTIVE'");args.add(q.effectiveOn());args.add(q.effectiveOn());} + if(q.keyword()!=null&&!q.keyword().isBlank()) {sql.append(" AND (origin_city LIKE ? OR destination_code LIKE ? OR party_name LIKE ? OR quote_no LIKE ?)");for(int i=0;i<4;i++) args.add("%"+q.keyword().trim()+"%");} + sql.append(" ORDER BY updated_at DESC,id DESC LIMIT 5001"); return jdbc.query(sql.toString(),MAPPER,args.toArray()); + } + void insert(Row r,FleetQuoteModels.Draft d,String natural,String operator,String key) { + jdbc.update("INSERT INTO ll_fleet_quote(id,quote_no,kind,party_id,party_name,origin_city,destination_code,valid_from,valid_to,status,natural_key,details_json,record_version,create_key,create_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + r.id(),r.quoteNo(),d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),key,r.createHash(),operator,r.updatedAt(),r.updatedAt()); + } + void update(Row r,FleetQuoteModels.Draft d,String natural) { + jdbc.update("UPDATE ll_fleet_quote SET kind=?,party_id=?,party_name=?,origin_city=?,destination_code=?,valid_from=?,valid_to=?,status=?,natural_key=?,details_json=?,record_version=?,updated_at=? WHERE id=?", + d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),r.updatedAt(),r.id()); + } + void audit(long id,Row r,String action,String operator,String snapshot) { + jdbc.update("INSERT INTO ll_fleet_quote_history(id,quote_id,record_version,action,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?)",id,r.id(),r.version(),action,operator,r.updatedAt(),snapshot); + } + List history(long id) {return jdbc.query("SELECT * FROM ll_fleet_quote_history WHERE quote_id=? ORDER BY record_version DESC LIMIT 200",(r,i)->new Audit(r.getLong("record_version"),r.getString("action"),r.getTimestamp("occurred_at").toLocalDateTime(),r.getString("snapshot_json")),id);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java new file mode 100644 index 000000000..86f480fe3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class FleetQuoteService { + private final FleetQuoteRepository repository; private final FleetQuoteDirectory directory; private final ObjectMapper json; + public FleetQuoteService(FleetQuoteRepository repository,FleetQuoteDirectory directory,ObjectMapper json) {this.repository=repository;this.directory=directory;this.json=json;} + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult page(FleetQuoteModels.Query q) { + var rows=list(q);long total=rows.size();int start=(int)Math.min(total,((long)q.page()-1)*q.size()); + return new PageResult<>(rows.subList(start,Math.min(rows.size(),start+q.size())),q.page(),q.size(),total,(total+q.size()-1)/q.size()); + } + private List list(FleetQuoteModels.Query q) { + var rows=repository.list(q);if(rows.size()>5000)throw bad("超过5000条报价,请缩小筛选范围");return rows.stream().map(this::response).toList(); + } + @Transactional(readOnly=true) public FleetQuoteModels.Quote detail(String no) {return response(required(no,false));} + @Transactional(readOnly=true) public List history(String no) { + return repository.history(required(no,false).id()).stream().map(r->new FleetQuoteModels.History(r.version(),r.action(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.snapshot(),FleetQuoteModels.Quote.class))).toList(); + } + @Transactional public FleetQuoteModels.Quote create(String operator,FleetQuoteModels.Create request) {return createOne(operator,request,"");} + private FleetQuoteModels.Quote createOne(String operator,FleetQuoteModels.Create request,String batchHash) { + var draft=normalize(request.details());String hash=hash(batchHash+write(draft));var existing=repository.replay(operator,request.idempotencyKey()); + if(existing!=null) {if(!existing.createHash().equals(hash))throw conflict("该请求键已用于不同报价");return read(repository.creationSnapshot(existing.id()),FleetQuoteModels.Quote.class);} + String name=directory.name(draft.kind(),draft.partyId());long id=IdWorker.getId(); + var row=new FleetQuoteRepository.Row(id,"FQ"+id,write(draft),name,"ACTIVE",0,hash,LocalDateTime.now(ZoneOffset.UTC)); + try {repository.insert(row,draft,natural(draft),operator,request.idempotencyKey());} + catch(DuplicateKeyException e) {throw conflict("报价重复或请求正在处理,请核对报价库后使用原请求重试");} + audit(row,"CREATE",operator);return response(row); + } + @Transactional public FleetQuoteModels.Quote update(String no,String operator,FleetQuoteModels.Update request) { + var old=required(no,true);version(old,request.recordVersion());var draft=normalize(request.details()); + if(!draft.kind().equals(read(old.details(),FleetQuoteModels.Draft.class).kind())) throw bad("报价用途不能变更,请另建采购或销售报价"); + var row=new FleetQuoteRepository.Row(old.id(),no,write(draft),directory.name(draft.kind(),draft.partyId()),old.status(),old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC)); + try {repository.update(row,draft,natural(draft));}catch(DuplicateKeyException e){throw conflict("同一报价组合已存在");} + audit(row,"UPDATE",operator);return response(row); + } + @Transactional public FleetQuoteModels.Quote status(String no,String operator,FleetQuoteModels.Status request) {return changeStatus(no,operator,request.recordVersion(),request.status());} + @Transactional public FleetQuoteModels.Quote delete(String no,String operator,long version) {return changeStatus(no,operator,version,"DELETED");} + private FleetQuoteModels.Quote changeStatus(String no,String operator,long expected,String status) { + var old=required(no,true);version(old,expected);var draft=read(old.details(),FleetQuoteModels.Draft.class); + if("ACTIVE".equals(status))directory.name(draft.kind(),draft.partyId()); + var row=new FleetQuoteRepository.Row(old.id(),no,old.details(),old.partyName(),status,old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC)); + repository.update(row,draft,"DELETED".equals(status)?hash("DELETED:"+no):natural(draft));audit(row,status,operator);return response(row); + } + @Transactional public FleetQuoteModels.ImportResult importRows(String operator,FleetQuoteModels.Import request) { + var rows=new ArrayList(); + String batchHash=hash(write(request.rows().stream().map(FleetQuoteService::normalize).toList())); + for(int i=0;iT read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("报价快照无法读取",e);}} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private static String cell(Object o){String s=o==null?"":o.toString();if(s.matches("(?s)^[\\s]*[=+@-].*"))s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";} + private static ResponseStatusException bad(String s){return new ResponseStatusException(HttpStatus.BAD_REQUEST,s);} + private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java new file mode 100644 index 000000000..a7a469fa3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="warehouse-service",contextId="fleetQuoteSupplierClient",url="${baiyue.clients.warehouse.url:}") +public interface FleetQuoteSupplierClient { + @GetMapping("/internal/warehouse/v1/suppliers/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("supplierId") long supplierId, + @RequestParam("requireActive") boolean requireActive,@RequestParam("category") String category); + record Supplier(long supplierId,String name,String category,String status,List carrierCodes,String truckingScope) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java new file mode 100644 index 000000000..fcb1bac40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/quotes") +public class PlatformFleetQuoteController { + private final FleetQuoteService service; private final FleetCustomerDirectory customers; + public PlatformFleetQuoteController(FleetQuoteService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> page(@Valid FleetQuoteModels.Query query){return ApiResponse.success(service.page(query));} + @GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> customers(@RequestParam(required=false) @Size(max=128) String keyword){return ApiResponse.success(customers.options(keyword));} + @GetMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse detail(@PathVariable String quoteNo){return ApiResponse.success(service.detail(quoteNo));} + @GetMapping("/{quoteNo}/history") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> history(@PathVariable String quoteNo){return ApiResponse.success(service.history(quoteNo));} + @PostMapping @PreAuthorize("hasAuthority('platform:fleet:quote:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Create request){return ApiResponse.success(service.create(operator(user),request));} + @PutMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Update request){return ApiResponse.success(service.update(quoteNo,operator(user),request));} + @PatchMapping("/{quoteNo}/status") @PreAuthorize("hasAuthority('platform:fleet:quote:status')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Status request){return ApiResponse.success(service.status(quoteNo,operator(user),request));} + @DeleteMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:delete')") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@RequestParam @Min(0) long recordVersion){return ApiResponse.success(service.delete(quoteNo,operator(user),recordVersion));} + @PostMapping("/import") @PreAuthorize("hasAuthority('platform:fleet:quote:import')") + public ApiResponse importRows(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Import request){return ApiResponse.success(service.importRows(operator(user),request));} + @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:quote:export')") + public ResponseEntity export(@Valid FleetQuoteModels.Query query){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=fleet-quotes.csv").body(service.exportCsv(query));} + private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护报价");return user.userId().toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java new file mode 100644 index 000000000..db01842bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/operations") +public class PlatformTripOperationsController { + private final TripOperationsService service; + public PlatformTripOperationsController(TripOperationsService service) { this.service=service; } + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); } + @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')") + public ResponseEntity export(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { + return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) + .header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user))); + } + private static boolean canReadCosts(AuthenticatedUser user){return user!=null&&user.permissions().contains("platform:fleet:trip:list");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java new file mode 100644 index 000000000..9d764b8c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record") +public class PlatformTripOperationsRecordController { + private final TripOperationsRecordService service; + public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));} + @GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));} + @PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){ + if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录"); + return ApiResponse.success(service.save(tripNo,user.userId().toString(),request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java new file mode 100644 index 000000000..01b0d6991 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.reporting; + +import java.math.*; +import java.time.ZoneOffset; +import java.util.*; + +/** Every chart uses the exact same filtered departures as the table and summary. */ +final class TripOperationsCharts { + private TripOperationsCharts() { } + static TripOperationsModels.Charts from(List rows) { + var pallets=new TreeMap();var destinations=new TreeMap();var labels=new HashMap(); + var amounts=new TreeMap>();var rateAmounts=new TreeMap>();var quantities=new TreeMap>(); + for(var row:rows) { + String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString(); + pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add); + for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());} + for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add); + for(var rate:row.externalPalletRates()) { + rateAmounts.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,rate.agreedAmount(),BigDecimal::add); + quantities.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,BigDecimal.valueOf(rate.pricedPalletSpaces()),BigDecimal::add); + } + } + var average=new TreeMap>(); + rateAmounts.forEach((currency,days)->days.forEach((day,amount)->average.computeIfAbsent(currency,ignored->new TreeMap<>()).put(day, + amount.divide(quantities.get(currency).get(day),2,RoundingMode.HALF_UP)))); + var destinationPoints=destinations.entrySet().stream().map(e->new TripOperationsModels.Point(labels.get(e.getKey()),e.getValue())).toList(); + return new TripOperationsModels.Charts(List.of(new TripOperationsModels.Series("每日实运托盘","托",points(pallets))), + List.of(new TripOperationsModels.Series("目的地涉及车次","次",destinationPoints)),series(amounts,false),series(average,true)); + } + private static List points(Map values){return values.entrySet().stream().map(e->new TripOperationsModels.Point(e.getKey(),e.getValue())).toList();} + private static List series(Map> values,boolean average){return values.entrySet().stream().map(e->new TripOperationsModels.Series(e.getKey(),e.getKey()+(average?"/标准托位":""),points(e.getValue()))).toList();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java new file mode 100644 index 000000000..7111a9304 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.trip.FleetTripCostModels; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class TripOperationsModels { + private TripOperationsModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @NotNull Instant from, @NotNull Instant to, @Positive Long warehouseId, + @Size(max=128) String keyword, @Pattern(regexp="OWN|EXTERNAL") String sourceType, + @Pattern(regexp="IN_TRANSIT|COMPLETED") String status) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Money(String currency, BigDecimal amount) { } + public record Destination(String key,String label) { } + public record PalletRate(String currency,long pricedPalletSpaces,BigDecimal agreedAmount) { } + public record Point(String label,BigDecimal value) { } + public record Series(String label,String unit,List points) { } + public record Charts(List physicalPallets,List destinationTrips,List externalAgreedAmounts,List externalPalletRates) { } + public record Row(String tripNo, String status, String vehiclePlate, String driverName, + String warehouseName, Instant departedAt, Instant completedAt, List destinations, + long physicalPallets, long palletSpaces, long ownPackages, long externalPackages, + List externalAgreedAmounts, List externalPalletRates, TripOperationsRecordModels.Details measured, + String financeStatus, String costStatus, FleetTripCostModels.Summary costs) { + Row withCosts(FleetTripCostModels.Summary value, String state) { + return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value); + } + } + public record CostTotal(String currency,BigDecimal directCost,BigDecimal ownCost,BigDecimal externalCost) { } + public record CostSummary(boolean visible,Long unrecordedTrips,Long unallocatedTrips,List totals) { } + public record Summary(long trips, long inTransit, long completed, long physicalPallets, long palletSpaces, + long ownPackages, long externalPackages, List externalAgreedAmounts,CostSummary costs) { } + public record Result(PageResult page, Summary summary, Charts charts) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java new file mode 100644 index 000000000..f82624ace --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.fleet.reporting; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; + +public final class TripOperationsRecordModels { + private TripOperationsRecordModels() { } + public record Details( + @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles, + @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres, + @Size(max=1000) String remark) { } + public record Save(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotNull @Valid Details details, @Size(max=1000) String reason) { } + public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { } + public record History(long recordVersion, String reason, Instant occurredAt, Details details) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java new file mode 100644 index 000000000..d2f4b46ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class TripOperationsRecordRepository { + record Snapshot(long version,String details,String reason,String requestHash,LocalDateTime occurredAt) { } + private final JdbcTemplate jdbc; + private static final RowMapper MAPPER=(r,i)->new Snapshot(r.getLong("record_version"),r.getString("details_json"),r.getString("reason"),r.getString("request_hash"),r.getTimestamp("occurred_at").toLocalDateTime()); + public TripOperationsRecordRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; } + boolean hasDeparted(String tripNo,boolean lock) { + return jdbc.query("SELECT departed_at FROM ll_fleet_trip WHERE trip_no=?"+(lock?" FOR UPDATE":""), + (r,i)->r.getTimestamp("departed_at")!=null,tripNo).stream().findFirst().orElse(false); + } + Snapshot latest(String tripNo) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT 1",MAPPER,tripNo).stream().findFirst().orElse(null); + } + Snapshot replay(String tripNo,String operator,String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? AND operator_id=? AND request_key=?",MAPPER,tripNo,operator,key).stream().findFirst().orElse(null); + } + void append(long id,String tripNo,String operator,String key,Snapshot row) { + jdbc.update("INSERT INTO ll_fleet_trip_operations_record(id,trip_no,record_version,details_json,reason,operator_id,request_key,request_hash,occurred_at) VALUES(?,?,?,?,?,?,?,?,?)", + id,tripNo,row.version(),row.details(),row.reason(),operator,key,row.requestHash(),row.occurredAt()); + } + long count(String tripNo) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record WHERE trip_no=?",Long.class,tripNo); } + List history(String tripNo,int page,int size) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT ? OFFSET ?",MAPPER,tripNo,size,((long)page-1)*size); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java new file mode 100644 index 000000000..7b6d9ad21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.HexFormat; + +/** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */ +@Service +public class TripOperationsRecordService { + private final TripOperationsRecordRepository repository; + private final ObjectMapper json; + public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json) { this.repository=repository;this.json=json; } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public TripOperationsRecordModels.Record get(String tripNo) { + requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo)); + } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult history(String tripNo,int page,int size) { + requireDeparture(tripNo,false);long total=repository.count(tripNo); + var rows=repository.history(tripNo,page,size).stream().map(r->new TripOperationsRecordModels.History(r.version(),r.reason(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.details()))).toList(); + return new PageResult<>(rows,page,size,total,(total+size-1)/size); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) { + requireDeparture(tripNo,true); + var d=request.details();var details=new TripOperationsRecordModels.Details(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark())); + String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录"; + String contents=write(details);String hash=hash(request.recordVersion()+":"+contents+":"+reason); + var replay=repository.replay(tripNo,operator,request.idempotencyKey()); + if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);} + var previous=repository.latest(tripNo);long version=previous==null?0:previous.version(); + if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正"); + if(previous!=null && previous.details().equals(contents))return response(tripNo,previous); + if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null)return response(tripNo,null); + var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC)); + repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row); + } + private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");} + private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) { + return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null),null) + :new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC)); + } + private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}} + private String write(Object value) {try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}} + private static String trim(String s){return s==null||s.isBlank()?null:s.trim();} + private static BigDecimal decimal(BigDecimal value){return value==null?null:value.stripTrailingZeros();} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java new file mode 100644 index 000000000..762fc6041 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import java.time.*; +import java.util.*; + +@Repository +public class TripOperationsRepository { + record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured) { } + private final JdbcTemplate jdbc; + public TripOperationsRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + List departures(TripOperationsModels.Query query) { + StringBuilder sql = new StringBuilder(""" + SELECT t.trip_no,t.status,t.completed_at, + (SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' + ORDER BY h.record_version LIMIT 1) AS snapshot_json, + (SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no + ORDER BY r.record_version DESC LIMIT 1) AS measured_json + FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ? + """); + var args = new ArrayList(); + args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC)); + if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); } + if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); } + // Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot. + sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001"); + return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"), + r.getTimestamp("completed_at") == null ? null : r.getTimestamp("completed_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + r.getString("snapshot_json"),r.getString("measured_json")), args.toArray()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java new file mode 100644 index 000000000..13deda172 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +@Service +@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) +public class TripOperationsService { + private final TripOperationsRepository repository; + private final ObjectMapper json; + private final FleetTripCostService costs; + public TripOperationsService(TripOperationsRepository repository, ObjectMapper json,FleetTripCostService costs) { this.repository=repository; this.json=json;this.costs=costs; } + public TripOperationsModels.Result report(TripOperationsModels.Query query,boolean costVisible) { + var rows = rows(query,costVisible); long total = rows.size(); + int start = (int) Math.min(total, ((long) query.page()-1)*query.size()); + return new TripOperationsModels.Result(new PageResult<>(rows.subList(start, Math.min(rows.size(),start+query.size())), + query.page(),query.size(),total,(total+query.size()-1)/query.size()), summary(rows,costVisible),TripOperationsCharts.from(rows)); + } + List rows(TripOperationsModels.Query query,boolean costVisible) { + if (query.from() == null || query.to() == null || !query.to().isAfter(query.from()) + || Duration.between(query.from(),query.to()).compareTo(Duration.ofDays(366))>0) throw bad("请选择不超过366天的实际发车时间范围"); + var facts=repository.departures(query); + if (facts.size()>5000) throw bad("范围内超过5000车次,请缩小时间或仓库范围后查询或导出"); + String keyword=query.keyword()==null?"":query.keyword().trim().toLowerCase(Locale.ROOT); + var rows=facts.stream().map(this::project) + .filter(r -> keyword.isEmpty() || contains(r.tripNo(),keyword) || contains(r.vehiclePlate(),keyword) || contains(r.driverName(),keyword) || contains(r.warehouseName(),keyword) || r.destinations().stream().anyMatch(s -> contains(s.label(),keyword))) + .filter(r -> query.sourceType()==null || query.sourceType().isBlank() || ("OWN".equals(query.sourceType())?r.ownPackages()>0:r.externalPackages()>0)).toList(); + if(!costVisible)return rows.stream().map(r->r.withCosts(null,"FORBIDDEN")).toList(); + var result=new ArrayList(); + for(int start=0;start(); var money=new TreeMap(); var bookingNumbers=new HashSet(); + var rates=new ArrayList(); + if (snapshot.externalBookings()!=null) for (var booking:snapshot.externalBookings()) { + if (Set.of("DRAFT","CANCELLED").contains(booking.status())) continue; + if (!"BOUND".equals(booking.status()) || !bookingNumbers.add(booking.bookingNo())) throw new IllegalStateException("发车外接记录未完成关联或重复"); + if (booking.dispatchNo()==null || booking.agreedAmount()==null || booking.cargo()==null || booking.cargo().currency()==null) + throw new IllegalStateException("外接发车历史缺少约定报价或排车归属"); + if(booking.receipt()==null || booking.receipt().source()==null || booking.receipt().source().pallets()==null) + throw new IllegalStateException("外接发车历史缺少实收来源"); + long received=0; + for(var p:booking.receipt().source().pallets()) { + externalByPallet.merge(booking.dispatchNo()+":"+booking.customerId()+":"+p.warehousePalletId(),(long)p.packages(),Long::sum); + received+=p.packages(); + } + if(received!=booking.receipt().source().receivedPackages())throw new IllegalStateException("外接实收来源数量未核平"); + money.merge(booking.cargo().currency(),booking.agreedAmount(),BigDecimal::add); + if("PALLET".equals(booking.cargo().priceBasis())) { + if(booking.cargo().palletSpaces()==null || booking.cargo().palletSpaces()<1)throw new IllegalStateException("按托报价缺少确认计价托位"); + rates.add(new TripOperationsModels.PalletRate(booking.cargo().currency(),booking.cargo().palletSpaces(),booking.agreedAmount())); + } + } + var pallets=new HashSet(); long spaces=0; + for(var pallet:load.pallets()) { + if(!pallets.add(pallet.warehousePalletId())) throw new IllegalStateException("发车实物托盘重复"); + spaces+=pallet.palletSpaces(); + } + long own=0,external=0; var destinations=new TreeMap(); var seen=new HashSet(); + var shippedByPallet=new HashMap(); + for(var cargo:load.cargo()) { + if (!pallets.contains(cargo.warehousePalletId()) || !seen.add(cargo.dispatchNo()+":"+cargo.dispatchItemId())) + throw new IllegalStateException("发车装载货物未核平或重复"); + shippedByPallet.merge(cargo.dispatchNo()+":"+cargo.customerId()+":"+cargo.warehousePalletId(),(long)cargo.packages(),Long::sum); + if(cargo.destinationKey()==null || cargo.destinationLabel()==null)throw new IllegalStateException("发车货物缺少目的地依据"); + destinations.put(cargo.destinationKey(),cargo.destinationLabel()); + } + for(var entry:externalByPallet.entrySet()) if(entry.getValue()>shippedByPallet.getOrDefault(entry.getKey(),0L))throw new IllegalStateException("外接实收超过实际发车货量"); + external=externalByPallet.values().stream().mapToLong(Long::longValue).sum(); + own=shippedByPallet.values().stream().mapToLong(Long::longValue).sum()-external; + return new TripOperationsModels.Row(fact.tripNo(),fact.status(),trip.vehiclePlate(),trip.driverName(),trip.warehouseName(),trip.departedAt(),fact.completedAt(), + destinations.entrySet().stream().map(e->new TripOperationsModels.Destination(e.getKey(),e.getValue())).toList(),pallets.size(),spaces,own,external,amounts(money),List.copyOf(rates), + fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null); + } catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); } + } + static TripOperationsModels.Summary summary(List rows,boolean costVisible) { + var money=new TreeMap(); + rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add))); + return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(), + rows.stream().mapToLong(TripOperationsModels.Row::physicalPallets).sum(),rows.stream().mapToLong(TripOperationsModels.Row::palletSpaces).sum(), + rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible)); + } + private static TripOperationsModels.CostSummary costSummary(List rows,boolean visible) { + if(!visible)return new TripOperationsModels.CostSummary(false,null,null,List.of()); + var totals=new TreeMap();long unrecorded=0,unallocated=0; + for(var row:rows) { + if(row.costs().totals().isEmpty())unrecorded++; + else if(row.costs().allocationBlockReason()!=null)unallocated++; + for(var total:row.costs().totals()) { + var old=totals.get(total.currency()); + totals.put(total.currency(),old==null?new TripOperationsModels.CostTotal(total.currency(),total.directCost(),total.ownCost(),total.externalCost()) + :new TripOperationsModels.CostTotal(total.currency(),old.directCost().add(total.directCost()),addKnown(old.ownCost(),total.ownCost()),addKnown(old.externalCost(),total.externalCost()))); + } + } + return new TripOperationsModels.CostSummary(true,unrecorded,unallocated,List.copyOf(totals.values())); + } + private static BigDecimal addKnown(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);} + public byte[] exportCsv(TripOperationsModels.Query query,boolean costVisible) { + var csv=new StringBuilder("\uFEFF车次号,状态,车牌,司机,起运仓,实际发车UTC,货物结案UTC,目的地,实物托盘数,标准托位,自有箱数,外接箱数,外接约定金额(按币种),实际里程(mile),实际油耗(L),实测备注,应收与实收,直接成本\r\n"); + for(var r:rows(query,costVisible)) csv.append(String.join(",",cell(r.tripNo()),cell(r.status()),cell(r.vehiclePlate()),cell(r.driverName()),cell(r.warehouseName()),cell(r.departedAt()),cell(r.completedAt()), + cell(r.destinations().stream().map(TripOperationsModels.Destination::label).collect(java.util.stream.Collectors.joining(" / "))),cell(r.physicalPallets()),cell(r.palletSpaces()),cell(r.ownPackages()),cell(r.externalPackages()), + cell(r.externalAgreedAmounts().stream().map(m -> m.currency()+" "+m.amount().toPlainString()).collect(java.util.stream.Collectors.joining(" / "))), + cell(r.measured()==null?null:r.measured().actualDistanceMiles()),cell(r.measured()==null?null:r.measured().actualFuelLitres()),cell(r.measured()==null?null:r.measured().remark()),cell("未查询财务"),cell(costText(r)))).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + private static String costText(TripOperationsModels.Row row){return row.costs()==null?"无成本读取权限":row.costs().totals().isEmpty()?"未登记成本":row.costs().totals().stream().map(t->t.currency()+" "+t.directCost().toPlainString()).collect(java.util.stream.Collectors.joining(" / "));} + private static List amounts(Map money) { return money.entrySet().stream().map(e -> new TripOperationsModels.Money(e.getKey(),e.getValue())).toList(); } + private static boolean contains(String value,String key) { return value!=null && value.toLowerCase(Locale.ROOT).contains(key); } + static String cell(Object value) { String s=value==null?"":value.toString(); if(s.matches("(?s)^[\\s]*[=+@-].*")) s="'"+s; return "\""+s.replace("\"","\"\"")+"\""; } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java index bbae3c7f2..efcacaa39 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java @@ -25,6 +25,10 @@ public interface FleetDispatchGateway { void assign(Snapshot dispatch, Assignment assignment); FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock); FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock); + /** Actual departure/terminal evidence for an in-transit handover; does not replay departure. */ + com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock); + /** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */ + void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment); FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, String idempotencyKey, String remark, Instant departedAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java new file mode 100644 index 000000000..6570e542d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.util.*; + +/** Cost weights come only from the immutable physical departure, never a reservation or current inventory. */ +final class FleetTripCostBasis { + private FleetTripCostBasis() { } + record Share(String businessNo, String sourceType, String dispatchNo, long palletSpaces) { } + record Basis(String manifestHash, List shares, String blockReason) { } + + static Basis from(FleetTripModels.AuditSnapshot snapshot) { + if (snapshot == null || snapshot.confirmedLoad() == null) return blocked("尚无实际发车装载记录"); + var load = snapshot.confirmedLoad(); + if (load.pallets() == null || load.pallets().isEmpty() || load.cargo() == null || load.cargo().isEmpty()) + return blocked("发车装载记录缺少实运托盘或货物"); + var measured = new HashMap(); + for (var pallet : load.pallets()) { + if (pallet.palletSpaces() < 1 || measured.put(pallet.warehousePalletId(), pallet) != null) + return blocked("发车实测托位缺失或托盘重复"); + } + var cargoByPallet = new HashMap>(); + for (var cargo : load.cargo()) cargoByPallet.computeIfAbsent(cargo.warehousePalletId(), ignored -> new ArrayList<>()).add(cargo); + if (!measured.keySet().equals(cargoByPallet.keySet())) return blocked("发车货物与实测托盘未核平"); + var external = new HashMap(); + if (snapshot.externalBookings() != null) for (var booking : snapshot.externalBookings()) { + if ("DRAFT".equals(booking.status()) || "CANCELLED".equals(booking.status())) continue; + if (!"BOUND".equals(booking.status()) || booking.bookingNo() == null || booking.bookingNo().isBlank() + || booking.customerId() == null || booking.dispatchNo() == null || booking.dispatchNo().isBlank() + || booking.receipt() == null || booking.receipt().source() == null) + return blocked("发车外接货缺少实收及排车依据"); + var receipt = booking.receipt().source(); + if (receipt.pallets() == null || receipt.pallets().isEmpty()) return blocked("发车外接货缺少实收托盘"); + long packages = 0; + for (var pallet : receipt.pallets()) { + var cargo = cargoByPallet.get(pallet.warehousePalletId()); + if (external.put(pallet.warehousePalletId(), booking) != null || cargo == null + || cargo.stream().anyMatch(row -> !booking.dispatchNo().equals(row.dispatchNo()) || !booking.customerId().equals(row.customerId())) + || cargo.stream().mapToLong(FleetExecutionModels.Cargo::packages).sum() != pallet.packages()) + return blocked("外接实收与发车托盘归属未核平"); + packages += pallet.packages(); + } + if (packages != receipt.receivedPackages()) return blocked("外接实收总量与发车托盘未核平"); + } + var shares = new TreeMap(); + for (var entry : cargoByPallet.entrySet()) { + var owners = entry.getValue().stream().map(FleetExecutionModels.Cargo::dispatchNo).distinct().toList(); + if (owners.size() != 1) return blocked("同一实物托盘关联多张排车单,暂不可按托位自动分摊"); + var booking = external.get(entry.getKey()); + String number = booking == null ? owners.get(0) : booking.bookingNo(); + var previous = shares.get(number); + long spaces = measured.get(entry.getKey()).palletSpaces() + (previous == null ? 0 : previous.palletSpaces()); + shares.put(number, new Share(number, booking == null ? "OWN" : "EXTERNAL", owners.get(0), spaces)); + } + String reason = load.pallets().stream().map(FleetExecutionModels.Measurement::deliveryIndex).distinct().count() == 1 + ? null : "各票卸货站不同,暂不可按同路线托位自动分摊"; + return new Basis(load.manifestHash(), List.copyOf(shares.values()), reason); + } + private static Basis blocked(String reason) { return new Basis(null, List.of(), reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java new file mode 100644 index 000000000..cf8164332 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetTripCostModels { + private FleetTripCostModels() { } + public record Draft(@NotBlank @Pattern(regexp="FUEL|DRIVER|TOLL|PARKING|OTHER") String category, + @NotBlank @Pattern(regexp="CNY|USD") String currency, + @NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount, + @NotNull Instant incurredAt, @Size(max=1000) String remark) { } + public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details, @Size(max=1000) String reason) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record Cost(String costNo, String tripNo, Draft details, long recordVersion, Instant updatedAt) { } + public record Allocation(String businessNo, String sourceType, String dispatchNo, long palletSpaces, BigDecimal amount) { } + public record Total(String currency, BigDecimal directCost, List allocations, BigDecimal ownCost, BigDecimal externalCost) { } + public record Summary(Long departureVersion, String manifestHash, String allocationMethod, String allocationBlockReason, List totals) { } + public record Listing(PageResult page, Summary summary) { } + public record Snapshot(Cost cost, Summary summary) { } + public record History(long recordVersion, String action, String reason, Instant occurredAt, Snapshot snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java new file mode 100644 index 000000000..3da0d13db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.ArrayList; + +@Repository +public class FleetTripCostRepository { + record Row(long id, long tripId, String costNo, FleetTripCostModels.Draft details, long version, + String requestHash, LocalDateTime updatedAt) { } + record Amount(String currency, BigDecimal amount) { } + record Departure(long version, String snapshot) { } + record Audit(long version, String action, String reason, Instant occurredAt, String snapshot) { } + private static final RowMapper ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getString("cost_no"), + new FleetTripCostModels.Draft(r.getString("category"), r.getString("currency"), r.getBigDecimal("amount"), + r.getTimestamp("incurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("remark")), + r.getLong("record_version"), r.getString("request_hash"), r.getTimestamp("updated_at").toLocalDateTime()); + private final JdbcTemplate jdbc; + public FleetTripCostRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + Row find(long tripId, String number) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND cost_no=?", ROW, tripId, number).stream().findFirst().orElse(null); + } + Row created(long tripId, String operator, String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND created_by=? AND request_key=?", ROW, tripId, operator, key).stream().findFirst().orElse(null); + } + long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost WHERE trip_id=?", Long.class, tripId); } + List page(long tripId, FleetTripCostModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? ORDER BY incurred_at DESC,id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset()); + } + List totals(long tripId) { + return jdbc.query("SELECT currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id=? GROUP BY currency ORDER BY currency", + (r, ignored) -> new Amount(r.getString("currency"), r.getBigDecimal("total")), tripId); + } + Departure departure(long tripId) { + var rows = jdbc.query("SELECT record_version,snapshot_json FROM ll_fleet_trip_history WHERE trip_id=? AND action='DEPART'", + (r, ignored) -> new Departure(r.getLong("record_version"), r.getString("snapshot_json")), tripId); + if (rows.size() > 1) throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊"); + return rows.isEmpty() ? null : rows.get(0); + } + Map trips(Collection numbers) { + var result = new LinkedHashMap(); + jdbc.query("SELECT trip_no,id FROM ll_fleet_trip WHERE trip_no IN (" + placeholders(numbers.size()) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("trip_no"), r.getLong("id")), numbers.toArray()); + return result; + } + Map departures(Collection ids) { + var result = new LinkedHashMap(); + if (ids.isEmpty()) return result; + jdbc.query("SELECT trip_id,record_version,snapshot_json FROM ll_fleet_trip_history WHERE action='DEPART' AND trip_id IN (" + placeholders(ids.size()) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> { + if (result.put(r.getLong("trip_id"), new Departure(r.getLong("record_version"), r.getString("snapshot_json"))) != null) + throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊"); + }, ids.toArray()); + return result; + } + Map> totals(Collection ids) { + var result = new LinkedHashMap>(); + if (ids.isEmpty()) return result; + jdbc.query("SELECT trip_id,currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id IN (" + placeholders(ids.size()) + ") GROUP BY trip_id,currency ORDER BY trip_id,currency", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.computeIfAbsent(r.getLong("trip_id"), ignored -> new ArrayList<>()) + .add(new Amount(r.getString("currency"), r.getBigDecimal("total"))), ids.toArray()); + return result; + } + private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); } + void insert(Row row, String operator, String key) { + jdbc.update("INSERT INTO ll_fleet_trip_cost(id,trip_id,cost_no,category,currency,amount,incurred_at,remark,record_version,request_key,request_hash,created_by,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + row.id(), row.tripId(), row.costNo(), row.details().category(), row.details().currency(), row.details().amount(), + utc(row.details().incurredAt()), row.details().remark(), row.version(), key, row.requestHash(), operator, operator, row.updatedAt(), row.updatedAt()); + } + int update(Row row, long expected, String operator) { + return jdbc.update("UPDATE ll_fleet_trip_cost SET category=?,currency=?,amount=?,incurred_at=?,remark=?,record_version=?,updated_by=?,updated_at=? WHERE id=? AND record_version=?", + row.details().category(), row.details().currency(), row.details().amount(), utc(row.details().incurredAt()), row.details().remark(), row.version(), operator, row.updatedAt(), row.id(), expected); + } + void audit(long id, Row row, String operator, String action, String reason, String snapshot) { + jdbc.update("INSERT INTO ll_fleet_trip_cost_history(id,cost_id,record_version,action,reason,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?,?)", + id, row.id(), row.version(), action, reason, operator, row.updatedAt(), snapshot); + } + long historyCount(long id) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history WHERE cost_id=?", Long.class, id); } + List history(long id, FleetTripCostModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost_history WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?", + (r, ignored) -> new Audit(r.getLong("record_version"), r.getString("action"), r.getString("reason"), + r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("snapshot_json")), id, query.size(), query.offset()); + } + private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java new file mode 100644 index 000000000..a699c2ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class FleetTripCostService { + private final FleetTripMapper trips; + private final FleetTripCostRepository costs; + private final ObjectMapper json; + public FleetTripCostService(FleetTripMapper trips, FleetTripCostRepository costs, ObjectMapper json) { + this.trips = trips; this.costs = costs; this.json = json; + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public FleetTripCostModels.Listing list(String tripNo, FleetTripCostModels.Query query) { + var trip = required(tripNo, false); + long count = costs.count(trip.getId()); + return new FleetTripCostModels.Listing(new PageResult<>(costs.page(trip.getId(), query).stream().map(row -> response(tripNo, row)).toList(), + query.page(), query.size(), count, pages(count, query.size())), summary(trip)); + } + /** At most 100 distinct trips. Missing trips are omitted; existing trips with no costs have empty totals. */ + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public Map summaries(Collection tripNos) { + if (tripNos == null || tripNos.size() > 100 || tripNos.stream().anyMatch(no -> no == null || no.isBlank())) + throw error(HttpStatus.BAD_REQUEST, "批量成本查询最多100个有效车次号"); + if (tripNos.isEmpty()) return Map.of(); + var numbers = new LinkedHashSet<>(tripNos); + var tripsByNo = costs.trips(numbers); + var departures = costs.departures(tripsByNo.values()); + var amounts = costs.totals(tripsByNo.values()); + var result = new LinkedHashMap(); + for (var number : numbers) { + var id = tripsByNo.get(number); + if (id != null) result.put(number, summary(departures.get(id), amounts.getOrDefault(id, List.of()))); + } + return Collections.unmodifiableMap(result); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult history(String tripNo, String costNo, FleetTripCostModels.Query query) { + var trip = required(tripNo, false); var row = cost(trip.getId(), costNo); long count = costs.historyCount(row.id()); + return new PageResult<>(costs.history(row.id(), query).stream().map(h -> new FleetTripCostModels.History(h.version(), h.action(), h.reason(), h.occurredAt(), + read(h.snapshot(), FleetTripCostModels.Snapshot.class))).toList(), query.page(), query.size(), count, pages(count, query.size())); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public FleetTripCostModels.Cost create(String tripNo, String operator, FleetTripCostModels.Create request) { + var trip = required(tripNo, true); + String hash = digest(write(canonical(request.details()))); + var prior = costs.created(trip.getId(), operator, request.idempotencyKey()); + if (prior != null) { + if (!hash.equals(prior.requestHash())) throw error(HttpStatus.CONFLICT, "请求标识已用于其他成本内容"); + return response(tripNo, prior); + } + validate(request.details()); + var row = new FleetTripCostRepository.Row(IdWorker.getId(), trip.getId(), "TC-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT), + canonical(request.details()), 0, hash, LocalDateTime.now(ZoneOffset.UTC)); + costs.insert(row, operator, request.idempotencyKey()); + audit(trip, row, operator, "CREATE", "登记本趟直接成本"); + return response(tripNo, row); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public FleetTripCostModels.Cost update(String tripNo, String costNo, String operator, FleetTripCostModels.Update request) { + var trip = required(tripNo, true); var prior = cost(trip.getId(), costNo); + if (prior.version() != request.recordVersion()) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试"); + validate(request.details()); + if (canonical(prior.details()).equals(canonical(request.details()))) return response(tripNo, prior); + var row = new FleetTripCostRepository.Row(prior.id(), prior.tripId(), prior.costNo(), canonical(request.details()), prior.version() + 1, prior.requestHash(), LocalDateTime.now(ZoneOffset.UTC)); + if (costs.update(row, prior.version(), operator) != 1) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试"); + audit(trip, row, operator, "UPDATE", request.reason() == null || request.reason().isBlank() ? "更正本趟直接成本" : request.reason().trim()); + return response(tripNo, row); + } + private void audit(FleetTripEntity trip, FleetTripCostRepository.Row row, String operator, String action, String reason) { + costs.audit(IdWorker.getId(), row, operator, action, reason, write(new FleetTripCostModels.Snapshot(response(trip.getTripNo(), row), summary(trip)))); + } + private FleetTripCostModels.Summary summary(FleetTripEntity trip) { + return summary(costs.departure(trip.getId()), costs.totals(trip.getId())); + } + private FleetTripCostModels.Summary summary(FleetTripCostRepository.Departure departure, List amountsByCurrency) { + var basis = FleetTripCostBasis.from(departure == null ? null : read(departure.snapshot(), FleetTripModels.AuditSnapshot.class)); + var totals = new ArrayList(); + for (var total : amountsByCurrency) { + var allocations = new ArrayList(); + BigDecimal own = null, external = null; + if (basis.blockReason() == null) { + var amounts = new HashMap(); + TripCostAllocationPolicy.allocate(total.amount(), total.currency(), basis.shares().stream() + .map(share -> new TripCostAllocationPolicy.Share(share.businessNo(), share.palletSpaces())).toList()) + .forEach(part -> amounts.put(part.bookingNo(), part.amount())); + own = BigDecimal.ZERO.setScale(2); external = BigDecimal.ZERO.setScale(2); + for (var share : basis.shares()) { + var amount = amounts.get(share.businessNo()); + allocations.add(new FleetTripCostModels.Allocation(share.businessNo(), share.sourceType(), share.dispatchNo(), share.palletSpaces(), amount)); + if ("OWN".equals(share.sourceType())) own = own.add(amount); else external = external.add(amount); + } + } + totals.add(new FleetTripCostModels.Total(total.currency(), total.amount(), allocations, own, external)); + } + return new FleetTripCostModels.Summary(departure == null ? null : departure.version(), basis.manifestHash(), + basis.blockReason() == null ? "SAME_ROUTE_PALLET_SPACES" : null, basis.blockReason(), totals); + } + private FleetTripEntity required(String number, boolean lock) { + var trip = lock ? trips.lockByNo(number) : trips.findByNo(number); + if (trip == null) throw error(HttpStatus.NOT_FOUND, "车次不存在"); return trip; + } + private FleetTripCostRepository.Row cost(long tripId, String number) { + var row = costs.find(tripId, number); if (row == null) throw error(HttpStatus.NOT_FOUND, "本车次成本记录不存在"); return row; + } + private static FleetTripCostModels.Cost response(String tripNo, FleetTripCostRepository.Row row) { + return new FleetTripCostModels.Cost(row.costNo(), tripNo, row.details(), row.version(), row.updatedAt().toInstant(ZoneOffset.UTC)); + } + private static void validate(FleetTripCostModels.Draft draft) { + if (draft.incurredAt().isAfter(Instant.now())) throw error(HttpStatus.BAD_REQUEST, "实际费用时间不能晚于当前时间"); + } + private static FleetTripCostModels.Draft canonical(FleetTripCostModels.Draft draft) { + String remark = draft.remark() == null || draft.remark().isBlank() ? null : draft.remark().trim(); + return new FleetTripCostModels.Draft(draft.category(), draft.currency(), draft.amount().stripTrailingZeros(), + draft.incurredAt().truncatedTo(java.time.temporal.ChronoUnit.MILLIS), remark); + } + private String write(Object value) { + try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("成本快照无法保存", error); } + } + private T read(String value, Class type) { + try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException("成本依据无法读取", error); } + } + private static String digest(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static long pages(long count, int size) { return count / size + (count % size == 0 ? 0 : 1); } + private static ResponseStatusException error(HttpStatus status, String text) { return new ResponseStatusException(status, text); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index a6ae434a2..ab0cca722 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -209,7 +209,8 @@ public class FleetTripExecutionService { if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); if (reason == null) reason = loadingReviewBlockReason(trip); if (reason != null) throw conflict(reason); - Instant now = Instant.now(); + // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. + Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); trip.setStatus("DEPARTING"); if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); var results = new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java new file mode 100644 index 000000000..6effa8274 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs") +public class PlatformFleetTripCostController { + private final FleetTripCostService service; + public PlatformFleetTripCostController(FleetTripCostService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse list(@PathVariable String tripNo, @Valid FleetTripCostModels.Query query) { + return ApiResponse.success(service.list(tripNo, query)); + } + @GetMapping("/{costNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> history(@PathVariable String tripNo, @PathVariable String costNo, @Valid FleetTripCostModels.Query query) { + return ApiResponse.success(service.history(tripNo, costNo, query)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:cost-create')") + public ApiResponse create(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestBody @Valid FleetTripCostModels.Create request) { + return ApiResponse.success(service.create(tripNo, user.userId().toString(), request)); + } + @PutMapping("/{costNo}") + @PreAuthorize("hasAuthority('platform:fleet:trip:cost-edit')") + public ApiResponse update(@PathVariable String tripNo, @PathVariable String costNo, + @AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid FleetTripCostModels.Update request) { + return ApiResponse.success(service.update(tripNo, costNo, user.userId().toString(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java index 3e6b5616c..720cd2b0f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -8,8 +8,12 @@ import java.util.List; /** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */ @Mapper public interface VehicleFulfillmentMapper { - String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + String ORIGINAL_DEPARTURE = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})"; + String HANDOVER = "EXISTS (SELECT 1 FROM ll_fleet_trip_handover ho WHERE ho.trip_id=t.id " + + "AND JSON_UNQUOTE(JSON_EXTRACT(ho.snapshot_json,'$.replacement.vehicleId'))=#{vehicleKey} " + + "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)"; + String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")"; String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " + "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) " + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; @@ -17,7 +21,8 @@ public interface VehicleFulfillmentMapper { String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id " + "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 " - + "WHERE h.action='ASSIGN' AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' " + + "LEFT JOIN ll_fleet_trip_handover ho ON ho.trip_id=h.trip_id AND ho.trip_version=h.record_version " + + "WHERE (h.action='ASSIGN' OR (h.action='HANDOVER' AND ho.id IS NOT NULL)) AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' " + "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) " + "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE + " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))"; @@ -32,7 +37,8 @@ public interface VehicleFulfillmentMapper { @Param("offset") long offset, @Param("size") int size); @Select("SELECT COUNT(*)" + CHANGES) long countChanges(@Param("vehicleKey") String vehicleKey); - @Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,h.snapshot_json AS after_snapshot" + @Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot," + + "CASE WHEN h.action='HANDOVER' THEN JSON_SET(h.snapshot_json,'$.handover',JSON_EXTRACT(ho.snapshot_json,'$')) ELSE h.snapshot_json END AS after_snapshot" + CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}") List changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java index 2c8337b0b..82647e42b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.ZoneOffset; -import java.util.ArrayList; @Service @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) @@ -37,8 +36,9 @@ public class VehicleFulfillmentService { try { JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip"); if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照"); - var dispatches = new ArrayList(); - for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText()); + var dispatches = new java.util.LinkedHashSet(); + JsonNode associated = snapshot.hasNonNull("handover") ? snapshot.path("handover").path("cargo").path("remainingCargo") : snapshot.path("dispatches"); + for (JsonNode binding : associated) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText()); String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED" : key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN"; return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 9858946d1..ff7a259e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -17,7 +17,8 @@ public class UnloadCompletedOutboxRepository { EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, - EventTypes.WALLET_COMMAND_REQUESTED); + EventTypes.WALLET_COMMAND_REQUESTED, + EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); @Resource private LastlegEventOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 705de1fd8..f9fcca611 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1212,6 +1212,138 @@ CREATE TABLE ll_fleet_trip_history ( CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史'; +CREATE TABLE ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); diff --git a/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml new file mode 100644 index 000000000..c2bf97b16 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml @@ -0,0 +1,290 @@ +openapi: 3.0.3 +info: + title: 车辆在途交接 + version: 1.0.0 + description: 从不可变DEPART及真实派送终态核对剩余货物。交接不重新出库或收费,不重写原始发车。旧车辆和司机分别确认实际任务结束后释放。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/dispatches/{dispatchNo}/handover-status: + get: + description: lastleg:pda:operate及ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR;只读,仓库来自Token。只返回所选排车的原发车资源、逐派送当前资源及涉及该排车的最近交接,不返回其他客户货物或内部ID。不提供PDA交接确认写接口。 + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + responses: + '200': + description: 当前排车运输及交接状态 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + properties: + dispatchNo: { type: string } + tripNo: { type: string } + tripStatus: { type: string, enum: [IN_TRANSIT, COMPLETED] } + tripRecordVersion: { type: string } + originalDeparture: { $ref: '#/components/schemas/Transport' } + deliveries: + type: array + items: + type: object + properties: + deliveryNo: { type: string } + status: { type: string } + packages: { type: integer } + terminalAt: { type: string, format: date-time, nullable: true } + transport: { $ref: '#/components/schemas/Transport' } + latestHandover: + type: object + nullable: true + properties: + handoverNo: { type: string } + handedOverAt: { type: string, format: date-time } + location: { type: string } + previous: { $ref: '#/components/schemas/Transport' } + replacement: { $ref: '#/components/schemas/Transport' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers: + parameters: + - $ref: '#/components/parameters/TripNo' + get: + description: platform:fleet:trip:list;按车次分页读取交接及原资源任务结束记录。 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + responses: + '200': + description: 业务响应 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/Handover' } } + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + pages: { type: integer } + post: + description: platform:fleet:trip:handover;同车次请求键唯一,异内容拒绝;先锁车次及新资源,再锁原客户执行单复核。原资源仍占用,车次保持IN_TRANSIT,仅未完成派送更新资源。失败整体回滚。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [selection, cargoHash, location, idempotencyKey] + properties: + selection: { $ref: '#/components/schemas/Selection' } + cargoHash: { type: string, pattern: '^[0-9a-f]{64}$' } + location: { type: string, minLength: 1, maxLength: 500 } + reason: { type: string, maxLength: 1000 } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + responses: + '200': { $ref: '#/components/responses/Handover' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers/preview: + parameters: + - $ref: '#/components/parameters/TripNo' + post: + description: platform:fleet:trip:list;只核对不写入。资格、容量、占用和派送事实变化使cargoHash失效。已签收后另退货使用原签收事实,未签收退回必须有RETURN_COMPLETED证据。部分同托仍用原整托实测,不按箱数比例猜重量体积。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Selection' } + responses: + '200': + description: 交接预览 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + required: [tripNo, recordVersion, cargoHash, previous, replacement, capacity, cargo, driverQualification] + properties: + tripNo: { type: string } + recordVersion: { type: integer, format: int64 } + cargoHash: { type: string } + previous: { $ref: '#/components/schemas/Resource' } + replacement: { $ref: '#/components/schemas/Resource' } + capacity: { $ref: '#/components/schemas/Load' } + cargo: { $ref: '#/components/schemas/Inspection' } + driverQualification: + type: object + properties: + canDispatch: { type: boolean } + expiringSoon: { type: boolean } + reason: { type: string } + licenseExpiresOn: { type: string, format: date, nullable: true } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers/{handoverNo}/resources/release: + parameters: + - $ref: '#/components/parameters/TripNo' + - { name: handoverNo, in: path, required: true, schema: { type: string } } + post: + description: platform:fleet:trip:release;分别结束被接替车辆或司机任务,可早于整车货物结案。未变化资源仍由原车次结案后释放。原请求重放不操作后续占用,车辆状态与结束记录同事务提交。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, resourceKind, actualEndAt, location, idempotencyKey] + properties: + recordVersion: { type: integer, format: int64, minimum: 0, description: 交接记录版本 } + resourceKind: { type: string, enum: [VEHICLE, DRIVER] } + actualEndAt: { type: string, format: date-time } + location: { type: string, minLength: 1, maxLength: 500 } + vehicleStatus: { type: string, nullable: true, enum: [IN_SERVICE, MAINTENANCE, INACTIVE, RETURNED], description: 结束原车辆必填;司机不得提交 } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + responses: + '200': { $ref: '#/components/responses/Handover' } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + TripNo: { name: tripNo, in: path, required: true, schema: { type: string } } + responses: + Handover: + description: 已保存的交接事实及资源结束状态 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Handover' } + schemas: + Transport: + type: object + properties: + vehiclePlate: { type: string, nullable: true } + driverName: { type: string, nullable: true } + driverPhone: { type: string, nullable: true } + Envelope: + type: object + required: [code] + properties: + code: { type: string, example: '200' } + message: { type: string } + Selection: + type: object + required: [recordVersion, vehicleId, driverId, expectedDriverEndAt, handedOverAt] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + vehicleId: { type: string, pattern: '^[1-9][0-9]*$' } + driverId: { type: string, pattern: '^[1-9][0-9]*$' } + expectedDriverEndAt: { type: string, format: date-time } + handedOverAt: { type: string, format: date-time } + Resource: + type: object + properties: + vehicleId: { type: string } + vehiclePlate: { type: string, nullable: true } + driverId: { type: string } + driverName: { type: string, nullable: true } + driverPhone: { type: string, nullable: true } + Load: + type: object + properties: + palletSpaces: { type: integer } + weightKg: { type: number } + volumeCbm: { type: number } + Cargo: + type: object + properties: + customerId: { type: string, description: 隔离键,不作客户名称展示 } + dispatchNo: { type: string } + itemId: { type: string, description: 内部核对键,不作业务编号展示 } + palletId: { type: string, description: 内部核对键,不作托盘码展示 } + destinationKey: { type: string, description: 匹配键,私人地址摘要不可作地址展示 } + packages: { type: integer } + Delivery: + type: object + properties: + customerId: { type: string } + dispatchNo: { type: string } + deliveryNo: { type: string } + destinationKey: { type: string } + packages: { type: integer } + terminalAt: { type: string, format: date-time, nullable: true } + Inspection: + type: object + properties: + remainingCargo: { type: array, items: { $ref: '#/components/schemas/Cargo' } } + completedDeliveries: { type: array, items: { $ref: '#/components/schemas/Delivery' } } + remainingPalletIds: { type: array, uniqueItems: true, items: { type: string } } + occupied: { $ref: '#/components/schemas/Load' } + Snapshot: + type: object + properties: + previous: { $ref: '#/components/schemas/Resource' } + replacement: { $ref: '#/components/schemas/Resource' } + handedOverAt: { type: string, format: date-time } + location: { type: string } + reason: { type: string } + expectedDriverEndAt: { type: string, format: date-time } + capacity: { $ref: '#/components/schemas/Load' } + cargo: { $ref: '#/components/schemas/Inspection' } + executionEvidence: + type: array + items: + type: object + properties: + dispatchNo: { type: string } + customerId: { type: string } + warehouseId: { type: string } + shippedPackages: { type: integer } + deliveries: + type: array + items: + type: object + properties: + cargo: { $ref: '#/components/schemas/Delivery' } + status: { type: string } + recordVersion: { type: integer, format: int64 } + transport: { $ref: '#/components/schemas/Transport' } + operatorId: { type: string, description: 审计身份,不作姓名展示 } + carriedDeliveries: + type: array + description: 当次实际交接的未完成派送身份,用于车辆运输区段查询 + items: + type: object + properties: + customerId: { type: string } + deliveryNo: { type: string } + recordedAt: { type: string, format: date-time } + Released: + type: object + nullable: true + properties: + idempotencyKey: { type: string } + requestHash: { type: string } + actualEndAt: { type: string, format: date-time } + location: { type: string } + vehicleStatus: { type: string, nullable: true } + operatorId: { type: string } + recordedAt: { type: string, format: date-time } + Handover: + type: object + properties: + handoverNo: { type: string } + tripNo: { type: string } + recordVersion: { type: integer, format: int64 } + snapshot: { $ref: '#/components/schemas/Snapshot' } + vehicleRelease: { $ref: '#/components/schemas/Released' } + driverRelease: { $ref: '#/components/schemas/Released' } diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml new file mode 100644 index 000000000..4f43b2d5c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -0,0 +1,928 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "真实出车与自有外接运营统计", + "version": "1.0.0", + "description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;财务未查询明确NOT_QUERIED;成本只通过FleetTripCostService.summaries(Collection)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/api/platform/v1/last-leg/fleet/operations": { + "get": { + "description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "warehouseId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "sourceType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "OWN", + "EXTERNAL" + ] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "IN_TRANSIT", + "COMPLETED" + ] + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "page": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "records", + "page", + "size", + "total", + "pages" + ] + }, + "summary": { + "$ref": "#/components/schemas/Summary" + }, + "charts": { + "$ref": "#/components/schemas/Charts" + } + }, + "required": [ + "page", + "summary", + "charts" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/export": { + "get": { + "description": "platform:fleet:operations:export;同筛选同事实来源的CSV", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "warehouseId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "sourceType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "OWN", + "EXTERNAL" + ] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "IN_TRANSIT", + "COMPLETED" + ] + } + } + ], + "responses": { + "200": { + "description": "UTF-8 BOM CSV", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:operations:list;仅实际已发车车次;尚未登记返回版本0和空值,不伪造零里程/油耗", + "responses": { + "200": { + "description": "业务响应,成功code为200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/MeasuredRecord" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "put": { + "description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "details": { + "$ref": "#/components/schemas/Measured" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "nullable": true + } + }, + "required": [ + "recordVersion", + "idempotencyKey", + "details", + "reason" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应,成功code为200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/MeasuredRecord" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/history": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:operations:list;实测更正历史,按版本倒序分页", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "业务响应,成功code为200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MeasuredHistory" + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "records", + "page", + "size", + "total", + "pages" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "Money": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "amount": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "currency", + "amount" + ] + }, + "Row": { + "type": "object", + "properties": { + "tripNo": { + "type": "string" + }, + "status": { + "type": "string" + }, + "vehiclePlate": { + "type": "string", + "nullable": true + }, + "driverName": { + "type": "string", + "nullable": true + }, + "warehouseName": { + "type": "string", + "nullable": true + }, + "departedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "destinations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Destination" + } + }, + "physicalPallets": { + "type": "integer", + "minimum": 0 + }, + "palletSpaces": { + "type": "integer", + "minimum": 0 + }, + "ownPackages": { + "type": "integer", + "minimum": 0 + }, + "externalPackages": { + "type": "integer", + "minimum": 0 + }, + "externalAgreedAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Money" + } + }, + "financeStatus": { + "type": "string", + "enum": [ + "NOT_QUERIED" + ] + }, + "costStatus": { + "type": "string", + "enum": [ + "FORBIDDEN", + "NOT_RECORDED", + "AVAILABLE" + ] + }, + "externalPalletRates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PalletRate" + } + }, + "measured": { + "allOf": [ + { + "$ref": "#/components/schemas/Measured" + } + ], + "nullable": true + }, + "costs": { + "allOf": [ + { + "$ref": "./fleet-trip-costs-v1.yaml#/components/schemas/Summary" + } + ], + "nullable": true + } + }, + "required": [ + "tripNo", + "status", + "vehiclePlate", + "driverName", + "warehouseName", + "departedAt", + "completedAt", + "destinations", + "physicalPallets", + "palletSpaces", + "ownPackages", + "externalPackages", + "externalAgreedAmounts", + "financeStatus", + "costStatus", + "externalPalletRates", + "measured", + "costs" + ] + }, + "Summary": { + "type": "object", + "properties": { + "trips": { + "type": "integer", + "minimum": 0 + }, + "inTransit": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + }, + "physicalPallets": { + "type": "integer", + "minimum": 0 + }, + "palletSpaces": { + "type": "integer", + "minimum": 0 + }, + "ownPackages": { + "type": "integer", + "minimum": 0 + }, + "externalPackages": { + "type": "integer", + "minimum": 0 + }, + "externalAgreedAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Money" + } + }, + "costs": { + "$ref": "#/components/schemas/CostSummary" + } + }, + "required": [ + "trips", + "inTransit", + "completed", + "physicalPallets", + "palletSpaces", + "ownPackages", + "externalPackages", + "externalAgreedAmounts", + "costs" + ] + }, + "Measured": { + "type": "object", + "properties": { + "actualDistanceMiles": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "actualFuelLitres": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "remark": { + "type": "string", + "nullable": true, + "maxLength": 1000 + } + }, + "required": [ + "actualDistanceMiles", + "actualFuelLitres", + "remark" + ] + }, + "Destination": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "key", + "label" + ] + }, + "PalletRate": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "pricedPalletSpaces": { + "type": "integer", + "minimum": 0 + }, + "agreedAmount": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "currency", + "pricedPalletSpaces", + "agreedAmount" + ] + }, + "Series": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "label", + "value" + ] + } + } + }, + "required": [ + "label", + "unit", + "points" + ] + }, + "Charts": { + "type": "object", + "properties": { + "physicalPallets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + }, + "destinationTrips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + }, + "externalAgreedAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + }, + "externalPalletRates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + } + }, + "required": [ + "physicalPallets", + "destinationTrips", + "externalAgreedAmounts", + "externalPalletRates" + ] + }, + "MeasuredRecord": { + "type": "object", + "properties": { + "tripNo": { + "type": "string" + }, + "recordVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "details": { + "$ref": "#/components/schemas/Measured" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "tripNo", + "recordVersion", + "details", + "updatedAt" + ] + }, + "MeasuredHistory": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "reason": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "details": { + "$ref": "#/components/schemas/Measured" + } + }, + "required": [ + "recordVersion", + "reason", + "occurredAt", + "details" + ] + }, + "CostTotal": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "directCost": { + "type": "number", + "minimum": 0 + }, + "ownCost": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "externalCost": { + "type": "number", + "minimum": 0, + "nullable": true + } + }, + "required": [ + "currency", + "directCost", + "ownCost", + "externalCost" + ] + }, + "CostSummary": { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "unrecordedTrips": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "unallocatedTrips": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "totals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CostTotal" + } + } + }, + "required": [ + "visible", + "unrecordedTrips", + "unallocatedTrips", + "totals" + ] + } + } + }, + "x-integration-database": { + "owner": "总控负责集成模块基线与海外统一初始化SQL,专项不执行SQL。新增权限platform:fleet:operations:edit。", + "ddl": "CREATE TABLE ll_fleet_trip_operations_record (\n id BIGINT NOT NULL COMMENT '出车实测历史主键',\n trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',\n record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',\n details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',\n reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',\n operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',\n request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',\n request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',\n occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',\n PRIMARY KEY (id),\n UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),\n UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';" + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml new file mode 100644 index 000000000..d98691fea --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml @@ -0,0 +1,887 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "车辆采购与外接销售报价库", + "version": "1.0.0", + "description": "采购价关联公共TRUCKING+OVERSEAS有效供应商,销售价关联有效且具备下单资格的客户。报价仅为价格库,不写入成本/应收/应付,不自动覆盖车次成交快照。整车自提与整车派送按单,散板派送按托。日期为路线业务日(含失效当天),未自动匹配成交;同维度同有效期禁止重复,重叠有效期须由运营明确选择具体报价版本。CSV导入界面解析后提交结构化行,整批原子写入;不支持部分成功。查询/导出最多5000条,导入最多200行。历史接口仅返回最新200版。" + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/api/platform/v1/last-leg/fleet/quotes": { + "get": { + "description": "platform:fleet:quote:list;包含详情只读", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "PURCHASE", + "SALE" + ] + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + { + "name": "effectiveOn", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Quote" + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "records", + "page", + "size", + "total", + "pages" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "post": { + "description": "platform:fleet:quote:create;原操作人+幂等键唯一,异内容拒绝。目录校验失败不写入。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "details": { + "$ref": "#/components/schemas/Draft" + } + }, + "required": [ + "idempotencyKey", + "details" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/{quoteNo}": { + "parameters": [ + { + "name": "quoteNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:quote:list", + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "put": { + "description": "platform:fleet:quote:edit;行锁+版本,更正保留旧版,用途不可变", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "details": { + "$ref": "#/components/schemas/Draft" + } + }, + "required": [ + "recordVersion", + "details" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "delete": { + "description": "platform:fleet:quote:delete;保存DELETED历史,退出报价库;不删除已成交业务快照", + "parameters": [ + { + "name": "recordVersion", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/{quoteNo}/status": { + "parameters": [ + { + "name": "quoteNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "patch": { + "description": "platform:fleet:quote:status;启用时重新校验目录", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + "required": [ + "recordVersion", + "status" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/{quoteNo}/history": { + "parameters": [ + { + "name": "quoteNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:quote:list;最新200个不可变版本", + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/History" + } + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/customer-options": { + "get": { + "description": "platform:fleet:quote:list;复用IAM有效客户目录。供应商候选由Admin调用现有/platform/v1/suppliers,须platform:supplier:list,非此接口扩权。", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "contactName": { + "type": "string", + "nullable": true + }, + "contactPhone": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "code", + "name", + "contactName", + "contactPhone" + ] + } + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/import": { + "post": { + "description": "platform:fleet:quote:import;最多200行,所有行与审计同事务提交;原批次键的行数或内容变化拒绝", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 100 + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "$ref": "#/components/schemas/Draft" + } + } + }, + "required": [ + "idempotencyKey", + "rows" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "importedRows": { + "type": "integer", + "minimum": 0 + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Quote" + } + } + }, + "required": [ + "importedRows", + "records" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/export": { + "get": { + "description": "platform:fleet:quote:export;全筛选CSV,最多5000行,公式注入转义,空报价保留空值,币种不混合", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "PURCHASE", + "SALE" + ] + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + { + "name": "effectiveOn", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "UTF-8 BOM CSV", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "Draft": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "PURCHASE", + "SALE" + ] + }, + "partyId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "originCity": { + "type": "string", + "maxLength": 128 + }, + "destinationCode": { + "type": "string", + "maxLength": 64 + }, + "distanceMiles": { + "type": "number", + "minimum": 0, + "multipleOf": 0.1 + }, + "currency": { + "type": "string", + "enum": [ + "USD", + "CNY" + ] + }, + "selfPickupPrice": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "fullDeliveryPrice": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "looseDeliveryPrice": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "validFrom": { + "type": "string", + "format": "date" + }, + "validTo": { + "type": "string", + "format": "date" + }, + "remark": { + "type": "string", + "nullable": true, + "maxLength": 1000 + } + }, + "required": [ + "kind", + "partyId", + "originCity", + "destinationCode", + "distanceMiles", + "currency", + "validFrom", + "validTo" + ] + }, + "Quote": { + "type": "object", + "properties": { + "quoteNo": { + "type": "string" + }, + "details": { + "$ref": "#/components/schemas/Draft" + }, + "partyName": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE", + "DELETED" + ] + }, + "recordVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "quoteNo", + "details", + "partyName", + "status", + "recordVersion", + "updatedAt" + ] + }, + "History": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "action": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "snapshot": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "recordVersion", + "action", + "occurredAt", + "snapshot" + ] + } + } + }, + "x-integration-database": { + "owner": "总控集成:lastleg.sql及海外统一初始化SQL;本文件仅为字段与索引清单,不执行SQL", + "tables": { + "ll_fleet_quote": { + "comment": "车辆采购与外接销售报价", + "columns": { + "id": "BIGINT PRIMARY KEY COMMENT '报价记录主键'", + "quote_no": "VARCHAR(64) NOT NULL COMMENT '报价业务编号'", + "kind": "VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售'", + "party_id": "BIGINT NOT NULL COMMENT '供应商或客户主档标识'", + "party_name": "VARCHAR(160) NOT NULL COMMENT '往来单位名称快照'", + "origin_city": "VARCHAR(128) NOT NULL COMMENT '发货城市'", + "destination_code": "VARCHAR(64) NOT NULL COMMENT '目的仓业务编码'", + "valid_from": "DATE NOT NULL COMMENT '报价生效日期'", + "valid_to": "DATE NOT NULL COMMENT '报价失效日期含当天'", + "status": "VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态'", + "natural_key": "CHAR(64) NOT NULL COMMENT '报价组合去重摘要'", + "details_json": "JSON NOT NULL COMMENT '完整报价明细快照'", + "record_version": "BIGINT NOT NULL COMMENT '报价记录版本'", + "create_key": "VARCHAR(128) NOT NULL COMMENT '创建幂等键'", + "create_hash": "CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要'", + "created_by": "VARCHAR(64) NOT NULL COMMENT '创建操作人标识'", + "created_at": "DATETIME(3) NOT NULL COMMENT '创建时间UTC'", + "updated_at": "DATETIME(3) NOT NULL COMMENT '更新时间UTC'" + }, + "indexes": [ + "UNIQUE KEY uk_fleet_quote_no (quote_no)", + "UNIQUE KEY uk_fleet_quote_natural (natural_key)", + "UNIQUE KEY uk_fleet_quote_create (created_by,create_key)", + "KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)" + ] + }, + "ll_fleet_quote_history": { + "comment": "车辆报价不可变版本历史", + "columns": { + "id": "BIGINT PRIMARY KEY COMMENT '报价历史主键'", + "quote_id": "BIGINT NOT NULL COMMENT '报价记录标识'", + "record_version": "BIGINT NOT NULL COMMENT '报价记录版本'", + "action": "VARCHAR(24) NOT NULL COMMENT '报价操作类型'", + "operator_id": "VARCHAR(64) NOT NULL COMMENT '操作人标识'", + "occurred_at": "DATETIME(3) NOT NULL COMMENT '操作时间UTC'", + "snapshot_json": "JSON NOT NULL COMMENT '报价完整版本快照'" + }, + "indexes": [ + "UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)" + ] + } + } + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml new file mode 100644 index 000000000..cd4b1f7b2 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml @@ -0,0 +1,183 @@ +openapi: 3.0.3 +info: + title: 车次直接运营成本 + version: 1.0.0 + description: 运营登记与分摊,不生成客户账单、财务应付或付款事实。不同币种分别汇总;仅从不可变DEPART实物装载和BOUND外接实收形成权重。DRAFT/CANCELLED预订不参与,其他未完成关联的有效预订阻止自动分摊,不得归为自有货。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/trips/{tripNo}/costs: + parameters: + - $ref: '#/components/parameters/TripNo' + get: + description: platform:fleet:trip:list;分页记录和整趟汇总使用同一只读快照。没有登记成本时totals为空,不显示为已知零成本。 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + responses: + '200': + description: 业务响应,成功code为200 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Listing' } + post: + description: platform:fleet:trip:cost-create;按车次和操作人锁定创建幂等键,异载荷同键拒绝。金额末尾零与空白备注按同一内容核对;成本与当时汇总历史同事务保存。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey, details] + properties: + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + details: { $ref: '#/components/schemas/Draft' } + responses: + '200': { $ref: '#/components/responses/Cost' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/costs/{costNo}: + parameters: + - $ref: '#/components/parameters/TripNo' + - $ref: '#/components/parameters/CostNo' + put: + description: platform:fleet:trip:cost-edit;核对成本版本与车次归属,更正保留旧记录及当时分摊快照。原因选填,缺省使用标准动作说明。内容未变化返回原版本,不追加历史;旧版本即使内容相同仍拒绝。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, details] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + details: { $ref: '#/components/schemas/Draft' } + reason: { type: string, maxLength: 1000 } + responses: + '200': { $ref: '#/components/responses/Cost' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/costs/{costNo}/history: + parameters: + - $ref: '#/components/parameters/TripNo' + - $ref: '#/components/parameters/CostNo' + get: + description: platform:fleet:trip:list;只返回指定车次成本的不可变更正历史,金额为当时原始币种。 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + responses: + '200': + description: 历史分页,data包含records/page/size/total/pages + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/History' } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + TripNo: { name: tripNo, in: path, required: true, schema: { type: string } } + CostNo: { name: costNo, in: path, required: true, schema: { type: string } } + Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 10 } } + responses: + Cost: + description: 业务响应;A0404归属不存在,A0409版本或幂等冲突,A0422输入校验,实际发生时间不可为将来 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Cost' } + schemas: + Envelope: + type: object + properties: + code: { type: string } + message: { type: string } + requestId: { type: string } + traceId: { type: string } + Page: + type: object + properties: + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + Draft: + type: object + required: [category, currency, amount, incurredAt] + properties: + category: { type: string, enum: [FUEL, DRIVER, TOLL, PARKING, OTHER] } + currency: { type: string, enum: [CNY, USD] } + amount: { type: number, minimum: 0, maximum: 999999999999.99, multipleOf: 0.01 } + incurredAt: { type: string, format: date-time, description: 实际发生时间,统一按毫秒精度保存和核对 } + remark: { type: string, nullable: true, maxLength: 1000 } + Cost: + type: object + properties: + costNo: { type: string } + tripNo: { type: string } + details: { $ref: '#/components/schemas/Draft' } + recordVersion: { type: integer, format: int64 } + updatedAt: { type: string, format: date-time } + Allocation: + type: object + properties: + businessNo: { type: string, description: 自有货排车单号或外接预订业务号 } + sourceType: { type: string, enum: [OWN, EXTERNAL] } + dispatchNo: { type: string } + palletSpaces: { type: integer, format: int64 } + amount: { type: number } + Total: + type: object + properties: + currency: { type: string } + directCost: { type: number } + allocations: { type: array, items: { $ref: '#/components/schemas/Allocation' } } + ownCost: { type: number, nullable: true } + externalCost: { type: number, nullable: true } + Summary: + type: object + properties: + departureVersion: { type: integer, format: int64, nullable: true } + manifestHash: { type: string, nullable: true } + allocationMethod: { type: string, nullable: true, enum: [SAME_ROUTE_PALLET_SPACES] } + allocationBlockReason: { type: string, nullable: true } + totals: { type: array, items: { $ref: '#/components/schemas/Total' } } + Listing: + type: object + properties: + page: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/Cost' } } + summary: { $ref: '#/components/schemas/Summary' } + Snapshot: + type: object + properties: + cost: { $ref: '#/components/schemas/Cost' } + summary: { $ref: '#/components/schemas/Summary' } + History: + type: object + properties: + recordVersion: { type: integer, format: int64 } + action: { type: string, enum: [CREATE, UPDATE] } + reason: { type: string } + occurredAt: { type: string, format: date-time } + snapshot: { $ref: '#/components/schemas/Snapshot' } diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml index 2387545f1..6f06ac8c5 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml @@ -4,9 +4,9 @@ info: version: 1.0.0 description: >- 平台按稳定车辆ID读取Lastleg本库履约事实,不按当前车牌文本反查,不修改单据或历史。 - 计划中的派送归当前分配车辆;实际发车后按不可变DEPART历史中的车辆身份归属。 + 计划中的派送归当前分配车辆;实际发车后按不可变DEPART及在途交接快照中的车辆身份归属。接替车辆仅关联交接时未完成且属于原客户的派送。 关联排车和派送时同时匹配客户归属,不返回财务、附件、联系人或内部数据库主键。 - 更换记录依据相邻车次版本的ASSIGN快照,首次分配和资源未变化的操作不算更换。 + 更换记录依据相邻车次版本的ASSIGN或HANDOVER快照;在途交接仅列实际剩余货物涉及的排车单,首次分配和资源未变化的操作不算更换。 旧车和新车均能读取同一更换事实;仅换司机也保留。返回历史车牌/姓名,不用当前主档覆盖。 不生成原型示例记录;没有正式车次关联的旧派送不通过车牌猜测归属。 security: [{bearerAuth: []}] diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml index 6ee0ea42c..4c100bad1 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml @@ -10,10 +10,70 @@ info: 照片最多20张,每张非空JPG/PNG且最多10MiB;File按平台0、FLEET_VEHICLE_RECORD_PHOTO和记录内部主键复合归属,验证内容签名。 维修和救援项目、地点独立保存;处理中或已完成须有处理人,异常须有明确分类。 费用只是运维申报金额,填金额则必须填有效币种;未填金额和零金额有区别,不生成应付或付款。 - 维修/救援paymentStatus当前固定UNLINKED表示未关联财务,不得由processingStatus推定为已付款,其他类型为空。 + 运维响应paymentStatus为空,不保存可变财务结论;维修/救援通过finance-status批量读取公司财务状态,不得由processingStatus推定付款。 + 显式提交公司成本时取已保存申报金额、服务端供应商与月份汇率,来源映射及Outbox同事务保存;不会自动入账或付款。 + 已提交费用须先在Finance作废才可修改费用项目/金额/币种或重新提交;处理进度、地点和处理人仍可独立更新。 不提供物理删除;编辑与上传历史同事务不可变保存。本接口不提供财务结算或车辆占用/状态自动变更。 security: [{bearerAuth: []}] paths: + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/finance-status: + parameters: [{$ref: '#/components/parameters/Vehicle'}] + post: + description: platform:overseas-master-data:vehicle:list;校验本车维修/救援记录,最多100条批量读取,不逐行调用Finance;依赖失败明确返回错误,不能伪造未关联或付款状态 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordNos] + properties: + recordNos: {type: array, minItems: 1, maxItems: 100, items: {type: string, minLength: 1, maxLength: 64}} + responses: + '200': + description: ApiResponse;data为每条请求记录的状态;Finance接收状态PENDING为待入账,APPROVED为已入账未付,不等于付款 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/FinanceStatus'}} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/finance: + parameters: + - {$ref: '#/components/parameters/Vehicle'} + - {name: recordNo, in: path, required: true, schema: {type: string}} + post: + description: platform:overseas-master-data:vehicle:record-finance-submit;不复用普通运维编辑权限。前端选择供应商候选另需platform:supplier:list。首次提交或Finance已作废后才可登记新来源版本,未知结果保留原键重试 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, supplierId, businessDate, idempotencyKey] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + supplierId: {type: integer, format: int64, minimum: 1} + businessDate: {type: string, format: date, description: 明确选择实际费用业务日期,用于获取业务月份有效汇率} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + responses: + '200': + description: ApiResponse;仅表示提交请求已登记,PENDING_FINANCE不是已入账或付款 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + recordNo: {type: string} + sourceVersion: {type: integer, format: int64} + submittedRecordVersion: {type: integer, format: int64} + replayed: {type: boolean} + status: {type: string, enum: [PENDING_FINANCE]} /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records: parameters: [{$ref: '#/components/parameters/Vehicle'}] get: @@ -172,4 +232,12 @@ components: recordVersion: {type: integer, format: int64, minimum: 0} updatedBy: {type: string} updatedAt: {type: string, format: date-time} - paymentStatus: {type: string, nullable: true, readOnly: true, enum: [UNLINKED], description: 维修/救援未关联财务时为UNLINKED,其余为空;不可用处理完成推定已付款} + paymentStatus: {type: string, nullable: true, readOnly: true, description: 运维快照不记录可变财务状态,当前为空;读取finance-status获取财务事实} + FinanceStatus: + type: object + properties: + recordNo: {type: string} + status: {type: string, enum: [UNLINKED, PENDING_FINANCE, FINANCE_ATTENTION, PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]} + sourceVersion: {type: integer, format: int64, nullable: true} + submittedRecordVersion: {type: integer, format: int64, nullable: true} + finance: {type: object, nullable: true, description: Finance公司费用Cost完整只读快照;来源版本及内容须与本地提交一致;未接收不虚构金额,具体结构见finance-vehicle-costs-v1.yaml的Cost} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java index 060d7b1ee..c3ddb0af8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -34,6 +34,27 @@ class DirectEventClientTest { verifyNoInteractions(firstleg); } + @Test void companyVehicleCostsUseTheirOwnFinanceEndpoint() { + var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); + var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + when(finance.vehicleCost("token", event)).thenReturn(ack); + assertThat(client.receive("token", event)).isSameAs(ack); + verify(finance).vehicleCost("token", event); + verifyNoMoreInteractions(finance); + verifyNoInteractions(firstleg); + } + + @Test void companyCostValidationConflictsArePermanentButDependencyFailuresRetry() { + var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); + for (String code : new String[]{"FIN_400", "FIN_404", "FIN_409", "FIN_503"}) { + when(finance.vehicleCost("token", event)).thenReturn(new ApiResponse<>(code, "rejected", null, null, null, Instant.now())); + assertThatThrownBy(() -> client.receive("token", event)) + .isInstanceOf(com.baiyue.common.web.EventDeliveryFailure.class) + .satisfies(error -> assertThat(com.baiyue.common.web.EventDeliveryFailure.isPermanent((Exception) error)).isEqualTo(!"FIN_503".equals(code))); + } + verifyNoInteractions(firstleg); + } + @Test void fulfillmentEventsAreSentOnlyToFirstleg() { for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { @@ -57,6 +78,8 @@ class DirectEventClientTest { EventTypes.WALLET_COMMAND_REQUESTED); assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance", EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + assertEndpoint(FinanceBusinessEventClient.class, "vehicleCost", "FINANCE_SERVICE_URL", "finance", + EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); } private void assertEndpoint(Class type, String method, String env, String property, String eventType) throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java new file mode 100644 index 000000000..e0019d4bc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.financefee.fleet; + +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCareFinanceAdapterTest { + @Test void publicationCannotStartItsOwnTransactionAndConversionDelegatesToTheFeeOwner() { + var converter = mock(OverseasFeeCurrencyConverter.class); + var outbox = mock(LastlegEventOutboxMapper.class); + var jdbc = mock(JdbcTemplate.class); + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-adapter-" + UUID.randomUUID(), "sa", ""); + var adapter = new VehicleCareFinanceAdapter(converter, outbox, jdbc, new ObjectMapper()); + var proxy = new ProxyFactory(adapter); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + var gateway = (VehicleCareFinanceGateway) proxy.getProxy(); + assertThatThrownBy(() -> gateway.publish(null)).isInstanceOf(IllegalTransactionStateException.class); + verifyNoInteractions(outbox); + var date = LocalDate.of(2026, 1, 1); + when(converter.convert("USD", BigDecimal.TEN, date)).thenReturn(new OverseasFeeCurrencyConverter.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00"))); + assertThat(gateway.convert("USD", BigDecimal.TEN, date)).isEqualTo(new VehicleCareFinanceGateway.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00"))); + verify(converter).convert("USD", BigDecimal.TEN, date); + } + @Test void deliveryStatusReadsAreBoundedAndKeepActualOutboxState() { + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-delivery-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_event_outbox(event_key VARCHAR(128),status VARCHAR(32))"); + jdbc.update("INSERT INTO ll_event_outbox VALUES('one','PENDING'),('two','DEAD'),('other','PUBLISHED')"); + var adapter = new VehicleCareFinanceAdapter(mock(OverseasFeeCurrencyConverter.class), mock(LastlegEventOutboxMapper.class), jdbc, new ObjectMapper()); + assertThat(adapter.deliveryStates(List.of())).isEmpty(); + assertThat(adapter.deliveryStates(List.of("one", "two"))).containsExactlyInAnyOrderEntriesOf(Map.of("one", "PENDING", "two", "DEAD")); + assertThatThrownBy(() -> adapter.deliveryStates(Collections.nCopies(101, "one"))).hasMessageContaining("最多100"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java new file mode 100644 index 000000000..57c7da87b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.fleet.exception; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetHandoverContractTest { + @AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); } + + @Test void listPermissionCanInspectButCannotHandoverOrEndResources() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", + List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); + var controller = context.getBean(PlatformFleetHandoverController.class); + var service = context.getBean(FleetHandoverService.class); + controller.page("TR-1", new FleetHandoverModels.Query(1, 10)); + controller.preview("TR-1", null); + verify(service).preview("TR-1", null); + assertThrows(AccessDeniedException.class, () -> controller.confirm("TR-1", null, null)); + assertThrows(AccessDeniedException.class, () -> controller.release("TR-1", "HO-1", null, null)); + } + } + + @Test void everyPublicMethodHasAContractAndIndependentWriteAuthority() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-handovers-v1.yaml"))); + Map paths = (Map) document.get("paths"); + String base = PlatformFleetHandoverController.class.getAnnotation(RequestMapping.class).value()[0]; + int count = 0; + for (var method : PlatformFleetHandoverController.class.getDeclaredMethods()) { + String suffix, verb; + if (method.isAnnotationPresent(GetMapping.class)) { var pathsOnMethod = method.getAnnotation(GetMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "get"; } + else if (method.isAnnotationPresent(PostMapping.class)) { var pathsOnMethod = method.getAnnotation(PostMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "post"; } + else continue; + var path = (Map) paths.get(base + suffix); assertNotNull(path, method.getName()); + var operation = (Map) path.get(verb); assertNotNull(operation, method.getName()); + var authority = method.getAnnotation(PreAuthorize.class); assertNotNull(authority); + String permission = authority.value().split("'")[1]; assertTrue(operation.get("description").toString().contains(permission)); + count++; + } + assertEquals(4, count); + } + + @Configuration + @EnableMethodSecurity + static class Config { + @Bean FleetHandoverService service() { return mock(FleetHandoverService.class); } + @Bean PlatformFleetHandoverController controller(FleetHandoverService service) { return new PlatformFleetHandoverController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java new file mode 100644 index 000000000..bb95bc67f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FleetHandoverPdaQueryTest { + final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class); + final FleetTripMapper trips = mock(FleetTripMapper.class); + final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class); + final FleetDispatchGateway dispatches = mock(FleetDispatchGateway.class); + final FleetHandoverRepository handovers = mock(FleetHandoverRepository.class); + final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + final FleetHandoverPdaQuery service = new FleetHandoverPdaQuery(bindings, trips, history, dispatches, handovers, json); + + @BeforeEach void setup() { + var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setCustomerId("CUSTOMER-A"); + binding.setTripId(1L); binding.setTripNo("TR-ONE"); binding.setPackageCount(30); + var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setWarehouseId(92L); + trip.setStatus("IN_TRANSIT"); trip.setRecordVersion(10L); + when(bindings.findByDispatchNo("PC-A")).thenReturn(binding); when(trips.findByNo("TR-ONE")).thenReturn(trip); + var departed = new FleetTripHistory(); departed.setSnapshotJson(""" + {"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL","driverName":"原司机"}} + """); + when(history.selectList(any())).thenReturn(List.of(departed)); + when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of( + new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")), + "DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)), + new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null), + "WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null))))); + } + + @Test void crossWarehouseFailsBeforeAnyCargoOrHistoryQuery() { + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(93, "PC-A")); + verifyNoInteractions(dispatches, history, handovers); + } + @Test void selectedDispatchRetainsOriginalAndPerDeliveryResourcesWithoutInternalIdentifiers() throws Exception { + var result = service.status(92, "PC-A"); + assertEquals("ORIGINAL", result.originalDeparture().vehiclePlate()); + assertEquals("ORIGINAL", result.deliveries().get(0).transport().vehiclePlate()); + assertEquals("REPLACEMENT", result.deliveries().get(1).transport().vehiclePlate()); + String serialized = json.writeValueAsString(result); + assertFalse(serialized.contains("customerId")); assertFalse(serialized.contains("warehouseId")); assertFalse(serialized.contains("vehicleId")); + verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("CUSTOMER-A") && value.contains("PC-A"))); + verify(dispatches, never()).handover(any(), any()); + } + @Test void staleBindingToAnotherCustomerCannotExposeDeliveryResources() { + when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-B", "92", 30, List.of())); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A")); + verifyNoInteractions(history, handovers); + } + @Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() { + var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value(); + assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + // Coverage instrumentation may add synthetic methods. Count all HTTP mappings (including + // composed mappings), so a newly exposed write route still fails this read-only contract. + var routes = java.util.Arrays.stream(PdaFleetHandoverController.class.getDeclaredMethods()) + .map(method -> org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation( + method, org.springframework.web.bind.annotation.RequestMapping.class)) + .filter(java.util.Objects::nonNull).toList(); + assertEquals(1, routes.size()); + assertArrayEquals(new org.springframework.web.bind.annotation.RequestMethod[] { org.springframework.web.bind.annotation.RequestMethod.GET }, routes.get(0).method()); + assertArrayEquals(new String[] { "/{dispatchNo}/handover-status" }, routes.get(0).path()); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> new PdaFleetHandoverController(service).status(null, "PC-A")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java new file mode 100644 index 000000000..27e0035ef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.*; +import static org.junit.jupiter.api.Assertions.*; + +class FleetHandoverPolicyTest { + private final Instant departure = Instant.parse("2026-10-03T01:00:00Z"); + private final Instant handover = departure.plusSeconds(7200); + private final Load capacity = load(2, 200, 4); + private final Cargo a = new Cargo("A", "PC-A", "1", "P1", "FBA:ONE", 10); + private final Cargo b = new Cargo("B", "PC-B", "2", "P1", "FBA:ONE", 20); + private final List measured = List.of(new Pallet("P1", load(1, 100, 2))); + + @Test void sharedPalletKeepsCustomerOwnershipAndCountsCapacityOnce() { + var result = inspectBoth(delivery(a, null), delivery(b, null)); + assertEquals(List.of(a, b), result.remainingCargo()); + assertEquals(load(1, 100, 2), result.occupied()); + assertEquals(1, result.remainingPalletIds().size()); + } + + @Test void signedCustomersCargoIsExcludedWithoutGuessingResidualWeight() { + var signed = delivery(a, departure.plusSeconds(60)); + var result = inspectBoth(signed, delivery(b, null)); + assertEquals(List.of(b), result.remainingCargo()); + assertEquals(List.of(signed), result.completedDeliveries()); + assertEquals(load(1, 100, 2), result.occupied()); + } + + @Test void unresolvedRefusalRemainsCargoUntilActualReturnFact() { + var result = inspectBoth(delivery(a, null), delivery(b, departure.plusSeconds(60))); + assertEquals(List.of(a), result.remainingCargo()); + } + + @Test void allTerminalCargoRequiresCompletionRatherThanEmptyHandover() { + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover), delivery(b, handover))); + } + + @Test void sameDestinationCannotMixCustomerOrDispatchFacts() { + var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null); + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong)); + } + + @Test void missingDuplicateAndChangedQuantityFactsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null))); + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null))); + var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null); + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong)); + } + + @Test void cannotBackdateHandoverAcrossLaterSignatureOrBeforeDeparture() { + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover.plusSeconds(1)), delivery(b, null))); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, departure.minusSeconds(1), handover, + List.of(a), List.of(delivery(a, null)), measured, capacity)); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover.plusSeconds(1), handover, + List.of(a), List.of(delivery(a, null)), measured, capacity)); + } + + @Test void rejectsAllThreeCapacityOverflows() { + for (Load tooSmall : List.of(load(1, 99, 2), load(1, 100, 1), load(0, 100, 2))) + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a), List.of(delivery(a, null)), measured, tooSmall)); + } + + @Test void missingOrDuplicateMeasurementsAndMixedDestinationPalletAreRejected() { + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a), List.of(delivery(a, null)), List.of(), capacity)); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a), List.of(delivery(a, null)), List.of(measured.get(0), measured.get(0)), capacity)); + var wrong = new Cargo("B", "PC-B", "2", "P1", "FBA:TWO", 20); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a, wrong), List.of(delivery(a, null), delivery(wrong, null)), measured, capacity)); + } + + @Test void fullyUnloadedPalletDoesNotConsumeReplacementCapacity() { + var other = new Cargo("B", "PC-B", "2", "P2", "FBA:TWO", 20); + var result = inspect(departure, handover, handover, List.of(a, other), + List.of(delivery(a, handover), delivery(other, null)), + List.of(measured.get(0), new Pallet("P2", load(1, 50, 1))), load(1, 50, 1)); + assertEquals(load(1, 50, 1), result.occupied()); + assertEquals(List.of(other), result.remainingCargo()); + } + + private Inspection inspectBoth(Delivery... deliveries) { + return inspect(departure, handover, handover, List.of(a, b), List.of(deliveries), measured, capacity); + } + private static Delivery delivery(Cargo cargo, Instant terminalAt) { + return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(), + cargo.destinationKey(), cargo.packages(), terminalAt); + } + private static Load load(int pallets, int kg, int cbm) { + return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java new file mode 100644 index 000000000..29049855f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java @@ -0,0 +1,171 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real transaction/repository/resource locks; credential, cargo and existing trip-history ports are isolated. */ +class FleetHandoverTransactionTest { + private JdbcTemplate jdbc; + private FleetHandoverService service; + private FleetHandoverRepository repository; + private FleetTripService history; + private FleetDispatchGateway dispatches; + private DriverService drivers; + private VehicleApplicationService vehicleService; + private Instant at, end; + private Selection selection; + + @BeforeEach void setup() throws Exception { + at = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS); end = at.plusSeconds(3600); + var source = new DriverManagerDataSource("jdbc:h2:mem:handover-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE trip_state(id BIGINT PRIMARY KEY,vehicle_id BIGINT,driver_id BIGINT,record_version BIGINT)"); + jdbc.update("INSERT INTO trip_state VALUES(1,11,21,7)"); + jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))"); + jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',11,1,'TR-ONE'),('DRIVER',21,1,'TR-ONE')"); + jdbc.execute(""" + CREATE TABLE ll_fleet_trip_handover( + id BIGINT PRIMARY KEY,trip_id BIGINT NOT NULL,trip_version BIGINT NOT NULL,trip_no VARCHAR(32) NOT NULL,handover_no VARCHAR(32) NOT NULL UNIQUE, + request_key VARCHAR(128) NOT NULL,request_hash CHAR(64) NOT NULL,record_version BIGINT NOT NULL, + handed_over_at TIMESTAMP(3) NOT NULL,snapshot_json LONGTEXT NOT NULL, + vehicle_release_json LONGTEXT,driver_release_json LONGTEXT,created_by VARCHAR(64) NOT NULL,created_at TIMESTAMP(3) NOT NULL, + UNIQUE(trip_id,request_key),UNIQUE(trip_id,trip_version)) + """); + var config = new MybatisConfiguration(); config.addMapper(FleetResourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config); + var resources = new SqlSessionTemplate(factory.getObject()).getMapper(FleetResourceMapper.class); + var trips = mock(FleetTripMapper.class); + when(trips.lockByNo("TR-ONE")).thenAnswer(call -> state()); when(trips.findByNo("TR-ONE")).thenAnswer(call -> state()); + var vehicles = mock(VehicleRepository.class); + when(vehicles.lockById(anyLong())).thenAnswer(call -> Optional.of(new VehicleRepository.VehicleSnapshot(call.getArgument(0), + "PLATE-" + call.getArgument(0), "TRUCK", BigDecimal.ONE, 10, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, + 0, "1", LocalDateTime.now(), 2, BigDecimal.valueOf(200), BigDecimal.valueOf(4)))); + drivers = mock(DriverService.class); + var qualification = new DriverQualificationPolicy.Qualification(true, true, DriverQualificationPolicy.Reason.ELIGIBLE, LocalDate.now().plusDays(15)); + when(drivers.requireDispatchable(anyLong(), any())).thenAnswer(call -> new DriverModels.Response(Long.toString(call.getArgument(0)), "DR-NEW", + new DriverModels.Draft("接替司机", "phone", "OWNED", "OVERSEAS", "license", "C", null, LocalDate.now().plusDays(15), "UTC", null, null), + true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null)); + var inspection = mock(FleetHandoverInspectionService.class); + var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10); + var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null); + var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10, + List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone")))); + when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(), + new FleetHandoverPolicy.Inspection(List.of(cargo), List.of(), Set.of("pallet"), new Load(1, BigDecimal.valueOf(100), BigDecimal.valueOf(2))), List.of(evidence))); + history = mock(FleetTripService.class); + doAnswer(call -> { + FleetTripEntity trip = call.getArgument(0); + jdbc.update("UPDATE trip_state SET vehicle_id=?,driver_id=?,record_version=record_version+1 WHERE id=1", trip.getVehicleId(), trip.getDriverId()); + return null; + }).when(history).recordExecution(any(), anyLong(), anyString(), anyString(), anyString()); + dispatches = mock(FleetDispatchGateway.class); vehicleService = mock(VehicleApplicationService.class); + repository = new FleetHandoverRepository(jdbc); + var target = new FleetHandoverService(trips, resources, history, dispatches, vehicles, vehicleService, drivers, inspection, repository, + new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (FleetHandoverService) proxy.getProxy(); + selection = new Selection(7L, 12L, 22L, end, at); + } + + @Test void handoverRetainsOldResourcesAndReplaysWithoutRepeatingDeliveryWrite() { + var command = command("handover-one"); + var result = service.confirm("TR-ONE", "1", command); + assertEquals(4, countResources()); assertEquals(12L, state().getVehicleId()); assertEquals(22L, state().getDriverId()); + assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId()); + assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo()); + verify(dispatches, times(1)).handover(any(), any()); + verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any()); + assertEquals(1, repository.count(1)); + } + + @Test void failureAfterResourceAndHistoryWritesRollsBackEverything() { + var command = command("handover-fail"); + doAnswer(call -> { jdbc.update("UPDATE trip_state SET vehicle_id=12 WHERE id=1"); throw new IllegalStateException("history failed"); }) + .when(history).recordExecution(any(), anyLong(), eq("HANDOVER"), anyString(), anyString()); + assertThrows(IllegalStateException.class, () -> service.confirm("TR-ONE", "1", command)); + assertEquals(2, countResources()); assertEquals(0, repository.count(1)); assertEquals(11L, state().getVehicleId()); + } + + @Test void stalePreviewAndOccupiedReplacementDoNotWrite() { + var command = command("handover-stale"); + jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',12,2,'TR-TWO')"); + assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", command)); + assertEquals(0, repository.count(1)); verifyNoInteractions(dispatches); + jdbc.update("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=12"); + var stale = new Confirm(selection, "0".repeat(64), "交接地点", null, "handover-stale"); + assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", stale)); assertEquals(2, countResources()); + } + + @Test void sameKeyWithDifferentContentFailsBeforeAnySecondMutation() { + var command = command("handover-replay"); service.confirm("TR-ONE", "1", command); + var changed = new Confirm(selection, command.cargoHash(), "另一地点", null, command.idempotencyKey()); + assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", changed)); + verify(dispatches, times(1)).handover(any(), any()); + } + + @Test void oldResourceReleaseReplayCannotReleaseItsNextTrip() { + var row = service.confirm("TR-ONE", "1", command("handover-release")); + var release = new Release(0L, "DRIVER", at.plusSeconds(1), "任务结束地点", null, "release-driver"); + var released = service.release("TR-ONE", row.handoverNo(), "1", release); + assertNotNull(released.driverRelease()); assertEquals(3, countResources()); + jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',21,2,'TR-TWO')"); + service.release("TR-ONE", row.handoverNo(), "1", release); + assertEquals(2L, jdbc.queryForObject("SELECT trip_id FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=21", Long.class)); + } + + @Test void failedVehicleStatusChangeRestoresOriginalReservation() { + var row = service.confirm("TR-ONE", "1", command("handover-vehicle")); + when(vehicleService.changeStatus(anyLong(), anyString(), any())).thenThrow(new IllegalStateException("status failed")); + assertThrows(IllegalStateException.class, () -> service.release("TR-ONE", row.handoverNo(), "1", + new Release(0L, "VEHICLE", at.plusSeconds(1), "维修站", "MAINTENANCE", "release-vehicle"))); + assertEquals(4, countResources()); assertNull(repository.find(1, row.handoverNo()).vehicleRelease()); + } + + @Test void currentUnchangedVehicleCannotBeReleasedAsOldResource() { + selection = new Selection(7L, 11L, 22L, end, at); + var row = service.confirm("TR-ONE", "1", command("handover-driver-only")); + assertEquals(3, countResources()); + assertThrows(RuntimeException.class, () -> service.release("TR-ONE", row.handoverNo(), "1", + new Release(0L, "VEHICLE", at.plusSeconds(1), "原地", "IN_SERVICE", "release-unchanged"))); + } + + private Confirm command(String key) { + var preview = service.preview("TR-ONE", selection); + assertTrue(preview.driverQualification().expiringSoon()); + return new Confirm(selection, preview.cargoHash(), "交接地点", null, key); + } + private int countResources() { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class); } + private FleetTripEntity state() { + return jdbc.queryForObject("SELECT * FROM trip_state WHERE id=1", (r, ignored) -> { + var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setStatus("IN_TRANSIT"); trip.setWarehouseId(92L); + trip.setVehicleId(r.getLong("vehicle_id")); trip.setVehiclePlate("PLATE-" + r.getLong("vehicle_id")); + trip.setDriverId(r.getLong("driver_id")); trip.setDriverName("司机"); trip.setDriverPhone("phone"); + trip.setRecordVersion(r.getLong("record_version")); trip.setDepartedAt(LocalDateTime.ofInstant(at.minusSeconds(3600), ZoneOffset.UTC)); + return trip; + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java new file mode 100644 index 000000000..141128e05 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTransitEvidenceTest { + @BeforeAll static void initializeDeliveryMetadata() { + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( + new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"), + DeliveryOrder.class); + } + + final DispatchOrderMapper orders = mock(DispatchOrderMapper.class); + final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class); + final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class); + final DeliveryOperationMapper operations = mock(DeliveryOperationMapper.class); + final DispatchPdaWorkflowService workflow = mock(DispatchPdaWorkflowService.class); + final DispatchFleetAdapter adapter = new DispatchFleetAdapter(orders, mock(DispatchPdaTaskMapper.class), + outbounds, deliveries, mock(DispatchItemMapper.class), mock(DispatchPdaPickMapper.class), workflow, operations); + final DispatchOrder dispatch = new DispatchOrder(); + final DeliveryOrder delivery = new DeliveryOrder(); + + FleetTransitEvidenceTest() { + dispatch.setId(11L); dispatch.setDispatchNo("PC-1"); dispatch.setCustomerId("CUSTOMER-A"); + dispatch.setOverseasWarehouseId("21"); dispatch.setStatus("DEPARTED"); dispatch.setShippedPackageCount(10); + var outbound = new OutboundOrder(); outbound.setId(31L); outbound.setStatus("OUTBOUNDED"); outbound.setPackageCount(10); + delivery.setId(41L); delivery.setDeliveryNo("DL-1"); delivery.setCustomerId("CUSTOMER-A"); + delivery.setDispatchOrderId(11L); delivery.setOutboundOrderId(31L); delivery.setOverseasWarehouseId("21"); + delivery.setPackageCount(10); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); + delivery.setDestinationType("FBA"); delivery.setDestinationWarehouseCode("ONT8"); + when(orders.findGlobalByDispatchNo("PC-1")).thenReturn(dispatch); + when(orders.lockGlobalByDispatchNo("PC-1")).thenReturn(dispatch); + when(outbounds.findByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound); + when(outbounds.lockByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound); + when(deliveries.findByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery)); + when(deliveries.lockByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery)); + } + + @Test void unresolvedReturnKeepsCargoAndUsesCustomerScopedLocks() { + delivery.setStatus("RETURN_PENDING"); + var fact = adapter.transitEvidence("PC-1", true); + assertNull(fact.deliveries().get(0).cargo().terminalAt()); + assertEquals("FBA:ONT8", fact.deliveries().get(0).cargo().destinationKey()); + var order = inOrder(orders, outbounds, deliveries); + order.verify(orders).lockGlobalByDispatchNo("PC-1"); + order.verify(outbounds).lockByDispatch("CUSTOMER-A", 11L); + order.verify(deliveries).lockByOutbound("CUSTOMER-A", 31L); + verifyNoInteractions(workflow); + } + + @Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() { + var signed = LocalDateTime.of(2026, 10, 3, 9, 0); + delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed); + var fact = adapter.transitEvidence("PC-1", false); + assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt()); + verifyNoInteractions(operations, workflow); + } + + @Test void unsignedReturnRequiresImmutableReturnOperation() { + delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now()); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + var returned = LocalDateTime.of(2026, 10, 3, 10, 0); + when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned); + assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(), + adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt()); + } + + @Test void crossCustomerAndUnbalancedFactsFailClosed() { + delivery.setCustomerId("CUSTOMER-B"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + delivery.setCustomerId("CUSTOMER-A"); delivery.setPackageCount(9); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + verifyNoInteractions(workflow); + } + + @Test void preDepartureAndCancelledDeliveryCannotBecomeHandoverCargo() { + dispatch.setStatus("READY_TO_DEPART"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + dispatch.setStatus("DEPARTED"); delivery.setStatus("CANCELLED"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + } + + @Test void handoverUpdatesOnlyUnfinishedDeliveryAndNeverCallsDeparture() { + var evidence = adapter.transitEvidence("PC-1", true); + when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery); + when(deliveries.update(isNull(), any())).thenReturn(1); + adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone")); + verify(deliveries).update(isNull(), any()); + verify(orders, never()).update(any(), any()); + verifyNoInteractions(workflow); + } + + @Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() { + delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0)); + var evidence = adapter.transitEvidence("PC-1", true); + var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone"); + adapter.handover(evidence, assignment); + verify(deliveries, never()).update(any(), any()); + delivery.setVersion(1L); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment)); + verifyNoInteractions(workflow); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java new file mode 100644 index 000000000..87e618f33 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java @@ -0,0 +1,125 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCareFinancePersistenceTest { + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private JdbcTemplate jdbc; + private VehicleCareFinanceService service; + private VehicleCareMapper records; + private VehicleCareFinanceClient finance; + private VehicleCareSupplierClient suppliers; + private VehicleCareEntity row; + @BeforeEach void setup() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:care-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_fleet_finance_source(id BIGINT PRIMARY KEY,record_id BIGINT NOT NULL,source_version BIGINT NOT NULL,record_version BIGINT NOT NULL,operator_id VARCHAR(64) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,event_id VARCHAR(128) NOT NULL,payload_json CLOB NOT NULL,created_at TIMESTAMP(3),UNIQUE(record_id,source_version),UNIQUE(record_id,operator_id,request_key),UNIQUE(event_id))"); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(128),event_type VARCHAR(64),event_version INT,event_key VARCHAR(255) UNIQUE,payload_json CLOB,status VARCHAR(32),retry_count INT,next_retry_at TIMESTAMP(3),published_at TIMESTAMP(3),locked_at TIMESTAMP(3),locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP(3))"); + var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var outbox = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); + records = mock(VehicleCareMapper.class); finance = mock(VehicleCareFinanceClient.class); suppliers = mock(VehicleCareSupplierClient.class); + row = new VehicleCareEntity(); row.setId(1L); row.setVehicleId(71L); row.setVehiclePlate("VEH-A"); row.setRecordNo("VR-ONE"); row.setRecordType("REPAIR"); row.setRecordVersion(0L); row.setDetailsJson(json.writeValueAsString(draft("10", "PENDING"))); + when(records.lock(71, "VR-ONE")).thenReturn(row); + when(records.findFinanceRecords(71, List.of("VR-ONE"))).thenReturn(List.of(row)); + when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of())); + when(suppliers.get("test-token", 8, true)).thenReturn(ApiResponse.success(new VehicleCareSupplierClient.Supplier(8, "维修供应商", null, "ACTIVE"))); + var converter = mock(OverseasFeeCurrencyConverter.class); + when(converter.convert(eq("USD"), any(), eq(LocalDate.of(2026, 1, 1)))).thenAnswer(call -> { + BigDecimal amount = call.getArgument(1); + return new OverseasFeeCurrencyConverter.Conversion("USD", amount.setScale(2), new BigDecimal("7.000000"), "2026-01", amount.multiply(new BigDecimal("7")).setScale(2)); + }); + var adapterProxy = new ProxyFactory(new VehicleCareFinanceAdapter(converter, outbox, jdbc, json)); adapterProxy.setProxyTargetClass(true); + adapterProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + var accounting = (VehicleCareFinanceGateway) adapterProxy.getProxy(); + var target = new VehicleCareFinanceService(records, jdbc, json, accounting, finance, suppliers, "test-token"); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (VehicleCareFinanceService) proxy.getProxy(); + } + private static VehicleCareModels.Draft draft(String amount, String processing) { + return new VehicleCareModels.Draft("REPAIR", Instant.parse("2026-01-01T00:00:00Z"), null, null, null, null, null, null, null, null, + new VehicleCareModels.Incident("轮胎损坏", "换轮胎", "维修站", "工作人员", processing, null, new BigDecimal(amount), "USD")); + } + private static VehicleCareFinanceModels.Submit command(String key) { return new VehicleCareFinanceModels.Submit(0L, 8L, LocalDate.of(2026, 1, 1), key); } + private VehicleCareFinanceModels.Source source() throws Exception { + return json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_fleet_finance_source ORDER BY source_version DESC LIMIT 1", String.class), VehicleCareFinanceModels.Source.class); + } + private void remote(String status) throws Exception { + var source = source(); + when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of(new VehicleCareFinanceModels.Cost("VC-ONE", "PLATFORM_DEFAULT", source, "维修供应商", "CNY", status, + "PAID".equals(status) ? source.amount() : BigDecimal.ZERO, "PAID".equals(status) ? BigDecimal.ZERO : source.amount(), 1)))); + } + @Test void submitWritesImmutableSourceAndOutboxTogetherAndUnknownResponseReplaysTheSameEvent() throws Exception { + var first = service.submit(71, "VR-ONE", "1", command("request-one")); + var replay = service.submit(71, "VR-ONE", "1", command("request-one")); + assertThat(first.status()).isEqualTo("PENDING_FINANCE"); assertThat(replay.replayed()).isTrue(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(1); + var envelope = json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class), EventEnvelope.class); + assertThat(envelope.eventType()).isEqualTo(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); + assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.payload().has("customerId")).isFalse(); + assertThat(source().amount()).isEqualByComparingTo("70.00"); + verify(finance, times(1)).query(anyString(), any()); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(0L, 9L, LocalDate.of(2026,1,1), "request-one"))).hasMessageContaining("其他内容"); + } + @Test void outboxFailureRollsBackTheSourceAndAllowsTheOriginalRequestToRetry() { + jdbc.execute("ALTER TABLE ll_event_outbox ADD CONSTRAINT reject_pending CHECK(status<>'PENDING')"); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero(); + jdbc.execute("ALTER TABLE ll_event_outbox DROP CONSTRAINT reject_pending"); + assertThat(service.submit(71, "VR-ONE", "1", command("request-one")).sourceVersion()).isEqualTo(1); + } + @Test void statusesDistinguishUnlinkedDeliveryFailureAndRealFinancePayment() throws Exception { + assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("UNLINKED"); + verifyNoInteractions(finance); + service.submit(71, "VR-ONE", "1", command("request-one")); + assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("PENDING_FINANCE"); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD'"); + assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("FINANCE_ATTENTION"); + remote("PAID"); + var status = service.statuses(71, List.of("VR-ONE")).get(0); + assertThat(status.status()).isEqualTo("PAID"); assertThat(status.finance().paidAmount()).isEqualByComparingTo("70"); + } + @Test void submittedAmountsStayFrozenUntilFinancialVoidButProcessingUpdatesContinue() throws Exception { + service.submit(71, "VR-ONE", "1", command("request-one")); remote("PENDING"); + assertThatThrownBy(() -> service.guardFinancialEdit(row, draft("20", "PENDING"))).hasMessageContaining("先在财务作废"); + clearInvocations(finance); + service.guardFinancialEdit(row, draft("10", "COMPLETED")); verifyNoInteractions(finance); + remote("PAID"); assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-two"))).hasMessageContaining("不可改写"); + remote("VOIDED"); service.guardFinancialEdit(row, draft("20", "PENDING")); + row.setDetailsJson(json.writeValueAsString(draft("20", "PENDING"))); + assertThat(service.submit(71, "VR-ONE", "1", command("request-two")).sourceVersion()).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(2); + } + @Test void wrongVehicleStaleVersionAndFinanceFailureCannotPretendToSubmit() { + assertThatThrownBy(() -> service.submit(72, "VR-ONE", "1", command("request-one"))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.statuses(72, List.of("VR-ONE"))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(1L,8L,LocalDate.of(2026,1,1),"request-one"))).hasMessageContaining("已变化"); + when(finance.query(anyString(), any())).thenReturn(null); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).hasMessageContaining("未确认入账或付款"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java index 026801b22..6efa92b94 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java @@ -50,7 +50,7 @@ class VehicleCarePersistenceTest { files = mock(VehicleCareFileGateway.class); when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8)); var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files, - new ObjectMapper().registerModule(new JavaTimeModule())); + new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class)); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); service = (VehicleCareService) proxy.getProxy(); @@ -128,7 +128,7 @@ class VehicleCarePersistenceTest { if (List.of("REPAIR", "RESCUE").contains(type)) { assertThat(row.details().incident().item()).isEqualTo("更换轮胎"); assertThat(row.details().incident().place()).isEqualTo("实际作业地点"); - assertThat(row.paymentStatus()).isEqualTo("UNLINKED"); + assertThat(row.paymentStatus()).isNull(); } } } @@ -137,7 +137,7 @@ class VehicleCarePersistenceTest { var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD"))); var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD"))); assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0"); - assertThat(completed.paymentStatus()).isEqualTo("UNLINKED"); + assertThat(completed.paymentStatus()).isNull(); assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE"); var history = service.history(vehicleA, created.recordNo(), 1, 20).records(); assertThat(history).hasSize(3); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java new file mode 100644 index 000000000..3cd34b78c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetQuoteDirectoryTest { + @Test void saleUsesCustomerAndPurchaseRequiresAnActiveOverseasTruckingSupplier() { + var customers=mock(FleetCustomerDirectory.class);var suppliers=mock(FleetQuoteSupplierClient.class); + var directory=new FleetQuoteDirectory(customers,suppliers,"test-only-token"); + when(customers.required(1)).thenReturn(new FleetCustomerDirectory.Customer("1","C001","委托客户",null,null)); + assertThat(directory.name("SALE",1)).isEqualTo("委托客户");verifyNoInteractions(suppliers); + when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"国内拖车","TRUCKING","ACTIVE",List.of(),"DOMESTIC"))); + assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("海外车队"); + when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"海外车队","TRUCKING","ACTIVE",List.of(),"OVERSEAS"))); + assertThat(directory.name("PURCHASE",2)).isEqualTo("海外车队"); + } + @Test void unavailableDirectoryDoesNotCreateFakeNames() { + var suppliers=mock(FleetQuoteSupplierClient.class);var directory=new FleetQuoteDirectory(mock(FleetCustomerDirectory.class),suppliers,"test-only-token"); + when(suppliers.get(anyString(),anyLong(),anyBoolean(),anyString())).thenThrow(new IllegalStateException("unavailable")); + assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("目录暂不可用"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java new file mode 100644 index 000000000..591993cbb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.LocalDate; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetQuotePersistenceTest { + private JdbcTemplate jdbc; private FleetQuoteService service; + @BeforeEach void setup() throws Exception { + var source=new DriverManagerDataSource("jdbc:h2:mem:quotes-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(source); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")) { + var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema); + assertThat(match.find()).as("总控集成表结构 %s",table).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); + } + var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("正式往来单位"); + var target=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource())); + service=(FleetQuoteService)proxy.getProxy(); + } + private FleetQuoteModels.Draft draft(String kind,String city,BigDecimal price) { + return new FleetQuoteModels.Draft(kind,1L,city,"ont8",new BigDecimal("10.0"),"USD",price,null,null,LocalDate.of(2026,1,1),LocalDate.of(2026,12,31),null); + } + @Test void nullPriceDiffersFromZeroAndSalesNeverAliasPurchase() { + assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("invalid-key",draft("SALE","LA",null)))).hasMessageContaining("至少填写"); + var sale=service.create("1",new FleetQuoteModels.Create("sale-key",draft("SALE","LA",BigDecimal.ZERO))); + var purchase=service.create("1",new FleetQuoteModels.Create("purchase-key",draft("PURCHASE","LA",BigDecimal.TEN))); + assertThat(sale.details().selfPickupPrice()).isEqualByComparingTo("0");assertThat(sale.details().fullDeliveryPrice()).isNull(); + assertThat(purchase.quoteNo()).isNotEqualTo(sale.quoteNo());assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isEqualTo(2); + } + @Test void replayAndCorrectionKeepVersionsAndRejectStaleWrites() { + var request=new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.TEN));var first=service.create("1",request); + assertThat(service.create("1",request).quoteNo()).isEqualTo(first.quoteNo()); + assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.ONE)))).hasMessageContaining("不同报价"); + service.update(first.quoteNo(),"1",new FleetQuoteModels.Update(0L,draft("SALE","LA",BigDecimal.ONE))); + assertThat(service.create("1",request).details().selfPickupPrice()).isEqualByComparingTo("10"); + assertThat(service.history(first.quoteNo())).hasSize(2);assertThat(service.history(first.quoteNo()).get(1).snapshot().details().selfPickupPrice()).isEqualByComparingTo("10"); + assertThatThrownBy(()->service.status(first.quoteNo(),"1",new FleetQuoteModels.Status(0L,"INACTIVE"))).hasMessageContaining("已变化"); + } + @Test void failedAtomicImportLeavesNoRowsAndChangedBatchCannotReuseKey() { + var row=draft("SALE","LA",BigDecimal.ONE); + assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,row)))).hasMessageContaining("重复"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote_history",Integer.class)).isZero(); + service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row))); + assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,draft("SALE","SF",BigDecimal.ONE))))).hasMessageContaining("不同报价"); + } + @Test void deletePreservesHistoryAndExportEscapesSpreadsheetFormulas() { + var created=service.create("1",new FleetQuoteModels.Create("formula-key",draft("SALE","=2+2",BigDecimal.ZERO))); + assertThat(new String(service.exportCsv(new FleetQuoteModels.Query(1,20,null,null,null,null)),java.nio.charset.StandardCharsets.UTF_8)).contains("'=2+2"); + service.delete(created.quoteNo(),"1",0);assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).total()).isZero(); + assertThat(service.history(created.quoteNo())).hasSize(2); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java new file mode 100644 index 000000000..d56fa7759 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.util.UUID; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TripOperationsRecordPersistenceTest { + private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service; + @BeforeEach void setup() throws Exception { + var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source); + jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)"); + var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); + repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy(); + } + private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);} + @Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() { + assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero(); + assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车"); + var saved=service.save("TR-A","1",request(0,"save-zero",BigDecimal.ZERO,null)); + assertThat(saved.details().actualDistanceMiles()).isEqualByComparingTo("0");assertThat(saved.details().actualFuelLitres()).isNull(); + assertThat(jdbc.queryForObject("SELECT status FROM ll_fleet_trip WHERE trip_no='TR-A'",String.class)).isEqualTo("IN_TRANSIT"); + } + @Test void replayReturnsOriginalSnapshotAndStaleUpdatesCannotOverwriteNewFacts() { + var first=request(0,"first-key",new BigDecimal("10.0"),BigDecimal.ONE);service.save("TR-A","1",first); + service.save("TR-A","1",request(1,"second-key",new BigDecimal("20"),BigDecimal.TEN)); + assertThat(service.save("TR-A","1",first).details().actualDistanceMiles()).isEqualByComparingTo("10"); + assertThatThrownBy(()->service.save("TR-A","1",request(1,"stale-key",BigDecimal.ONE,null))).hasMessageContaining("已变化"); + assertThatThrownBy(()->service.save("TR-A","1",request(0,"first-key",BigDecimal.ONE,null))).hasMessageContaining("其他实测内容"); + assertThat(service.history("TR-A",1,1).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isEqualByComparingTo("20"); + } + @Test void unchangedFactsDoNotInventExtraHistoryAndExplicitClearIsAudited() { + service.save("TR-A","1",request(0,"create-key",new BigDecimal("10.0"),null)); + assertThat(service.save("TR-A","1",request(1,"no-change",new BigDecimal("10.00"),null)).recordVersion()).isEqualTo(1); + service.save("TR-A","1",request(1,"clear-key",null,null)); + assertThat(service.history("TR-A",1,20).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull(); + } + @Test void failedAppendRollsBackAllMeasuredFacts() { + doAnswer(invocation->{invocation.callRealMethod();throw new IllegalStateException("audit-failure");}).when(repository).append(anyLong(),anyString(),anyString(),anyString(),any()); + assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java new file mode 100644 index 000000000..986e0f673 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.baiyue.lastleg.fleet.trip.FleetTripCostService; +import com.baiyue.lastleg.fleet.trip.FleetTripCostModels; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TripOperationsServiceTest { + static final String DEPARTURE=""" + {"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"}, + "confirmedLoad":{"cargo":[ + {"dispatchNo":"D-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","packages":10,"destinationKey":"FBA:ONT8","destinationLabel":"ONT8"}, + {"dispatchNo":"D-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P1","packages":5,"destinationKey":"FBA:LAX9","destinationLabel":"LAX9"}], + "pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}]}, + "externalBookings":[{"bookingNo":"FB1","status":"BOUND","customerId":"1","dispatchNo":"D-A","agreedAmount":0,"cargo":{"currency":"USD"}, + "receipt":{"source":{"receivedPackages":4,"pallets":[{"warehousePalletId":"P1","packages":4}]}}}]} + """; + @Test void mixedCustomersDestinationsAndCargoCountOneDepartureAndOnePhysicalPallet() { + var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null); + when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","COMPLETED",Instant.parse("2026-10-02T00:00:00Z"),DEPARTURE,null))); + var result=service.report(query,false);assertThat(result.summary().trips()).isEqualTo(1);assertThat(result.summary().physicalPallets()).isEqualTo(1);assertThat(result.summary().palletSpaces()).isEqualTo(2); + assertThat(result.summary().externalPackages()).isEqualTo(4);assertThat(result.summary().ownPackages()).isEqualTo(11); + var row=result.page().records().get(0);assertThat(row.vehiclePlate()).isEqualTo("OLD-PLATE");assertThat(row.financeStatus()).isEqualTo("NOT_QUERIED");assertThat(row.costStatus()).isEqualTo("FORBIDDEN"); + assertThat(row.externalAgreedAmounts().get(0).amount()).isEqualByComparingTo("0");verify(repo,times(1)).departures(query); + } + @Test void missingSnapshotAndOverstatedExternalQuantityFailInsteadOfInventingZero() { + var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,null,null))).hasMessageContaining("不可变历史"); + assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE.replace("\"receivedPackages\":4","\"receivedPackages\":20").replace("\"packages\":4","\"packages\":20"),null))).hasMessageContaining("超过实际发车"); + } + @Test void summaryUsesAllFilteredDeparturesNotCurrentPageAndSeparatesCurrencies() { + var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,"EXTERNAL",null); + when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("USD","CNY").replace("TR-A","TR-B"),null))); + var result=service.report(query,false);assertThat(result.page().records()).hasSize(1);assertThat(result.summary().trips()).isEqualTo(2);assertThat(result.summary().externalAgreedAmounts()).hasSize(2); + assertThat(result.charts().physicalPallets().get(0).points().get(0).value()).isEqualByComparingTo("2"); + assertThat(result.charts().destinationTrips().get(0).points()).hasSize(2); + assertThat(result.charts().externalPalletRates()).isEmpty(); + } + @Test void perPalletChartUsesWeightedConfirmedQuantityAndExcludesOrderPricing() { + var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + String a=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":20").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"PALLET\",\"palletSpaces\":2"); + String b=a.replace("\"agreedAmount\":20","\"agreedAmount\":10").replace("\"palletSpaces\":2","\"palletSpaces\":8"); + String order=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":100").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"ORDER\""); + var charts=TripOperationsCharts.from(List.of(service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,a,null)), + service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,b,null)),service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,order,null)))); + assertThat(charts.externalPalletRates().get(0).points().get(0).value()).isEqualByComparingTo("3.00"); + assertThat(charts.externalAgreedAmounts().get(0).points().get(0).value()).isEqualByComparingTo("130"); + } + @Test void costsComeFromOneBatchAndUnknownAllocationsNeverBecomeZero() { + var repo=mock(TripOperationsRepository.class);var costs=mock(FleetTripCostService.class); + var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),costs); + var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null); + when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("TR-A","TR-B"),null))); + var registered=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of(new FleetTripCostModels.Total("USD",new java.math.BigDecimal("10.00"),List.of(),null,null))); + var empty=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of()); + when(costs.summaries(List.of("TR-A","TR-B"))).thenReturn(java.util.Map.of("TR-A",registered,"TR-B",empty)); + var report=service.report(query,true);verify(costs,times(1)).summaries(List.of("TR-A","TR-B")); + assertThat(report.summary().costs().unrecordedTrips()).isEqualTo(1);assertThat(report.summary().costs().unallocatedTrips()).isEqualTo(1); + assertThat(report.summary().costs().totals().get(0).directCost()).isEqualByComparingTo("10.00");assertThat(report.summary().costs().totals().get(0).ownCost()).isNull(); + assertThat(report.page().records().get(1).costStatus()).isEqualTo("NOT_RECORDED"); + clearInvocations(costs);assertThat(service.report(query,false).summary().costs().visible()).isFalse();verifyNoInteractions(costs); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java new file mode 100644 index 000000000..b4b40606a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTripCostContractTest { + private static final String READ="platform:fleet:trip:list", CREATE="platform:fleet:trip:cost-create", EDIT="platform:fleet:trip:cost-edit"; + private void login(String... permissions) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused", + Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); + } + @Test void readingDoesNotGrantCostWritesAndPlanEditingDoesNotGrantCostEditing() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformFleetTripCostController.class); + var service = context.getBean(FleetTripCostService.class); + var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); + var query = new FleetTripCostModels.Query(1, 10); + var create = new FleetTripCostModels.Create("test-key", null); + var update = new FleetTripCostModels.Update(0L, null, null); + login(READ, "platform:fleet:trip:edit"); + controller.list("TR-ONE", query); controller.history("TR-ONE", "TC-ONE", query); + assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class); + login(CREATE); controller.create("TR-ONE", user, create); + assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class); + login(EDIT); controller.update("TR-ONE", "TC-ONE", user, update); + assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class); + login(); assertThatThrownBy(() -> controller.list("TR-ONE", query)).isInstanceOf(AccessDeniedException.class); + verify(service).list("TR-ONE", query); verify(service).history("TR-ONE", "TC-ONE", query); + verify(service).create("TR-ONE", "8", create); verify(service).update("TR-ONE", "TC-ONE", "8", update); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Test void everyPublicOperationAndPayloadFieldHasAnExplicitContract() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trip-costs-v1.yaml"))); + Map paths = (Map) document.get("paths"); + String base = PlatformFleetTripCostController.class.getAnnotation(RequestMapping.class).value()[0]; + for (var method : PlatformFleetTripCostController.class.getDeclaredMethods()) { + String verb, suffix; + if (method.isAnnotationPresent(GetMapping.class)) { verb="get"; var values=method.getAnnotation(GetMapping.class).value(); suffix=values.length==0 ? "" : values[0]; } + else if (method.isAnnotationPresent(PostMapping.class)) { verb="post"; suffix=""; } + else if (method.isAnnotationPresent(PutMapping.class)) { verb="put"; suffix=method.getAnnotation(PutMapping.class).value()[0]; } + else continue; + Map operation = (Map) ((Map) paths.get(base+suffix)).get(verb); + String expression = method.getAnnotation(PreAuthorize.class).value(); + for (String permission : List.of(READ, CREATE, EDIT)) if (expression.contains(permission)) + assertThat(operation.get("description").toString()).contains(permission); + } + Map schemas = (Map) ((Map) document.get("components")).get("schemas"); + for (var type : List.of(FleetTripCostModels.Cost.class, FleetTripCostModels.Draft.class, FleetTripCostModels.Allocation.class, + FleetTripCostModels.Summary.class, FleetTripCostModels.Total.class, FleetTripCostModels.Listing.class, + FleetTripCostModels.Snapshot.class, FleetTripCostModels.History.class)) { + Map properties = (Map) ((Map) schemas.get(type.getSimpleName())).get("properties"); + assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder( + Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new)); + } + assertThat(FleetTripCostService.class.getMethod("list", String.class, FleetTripCostModels.Query.class).getAnnotation(Transactional.class).readOnly()).isTrue(); + } + @Configuration @EnableMethodSecurity static class Config { + @Bean FleetTripCostService service() { return mock(FleetTripCostService.class); } + @Bean PlatformFleetTripCostController controller(FleetTripCostService service) { return new PlatformFleetTripCostController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java new file mode 100644 index 000000000..9240e8647 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java @@ -0,0 +1,189 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class FleetTripCostPersistenceTest { + private JdbcTemplate jdbc; + private FleetTripCostRepository repository; + private FleetTripCostService service; + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private static final FleetTripCostModels.Query PAGE = new FleetTripCostModels.Query(1, 10); + static final String DEPARTURE = """ + {"confirmedLoad":{"manifestHash":"frozen-departure", "cargo":[ + {"dispatchNo":"DS-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","palletNo":"PAL-1","packages":3,"destinationKey":"FBA:A"}, + {"dispatchNo":"DS-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P2","palletNo":"PAL-2","packages":2,"destinationKey":"FBA:A"}], + "pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}, + {"warehousePalletId":"P2","palletSpaces":1,"weightKg":50,"volumeCbm":1,"deliveryIndex":1}]}, + "externalBookings":[{"bookingNo":"FB-B","customerId":"2","status":"BOUND","dispatchNo":"DS-B", + "receipt":{"source":{"receivedPackages":2,"pallets":[{"warehousePalletId":"P2","packages":2}]}}}]} + """; + @BeforeEach void setup() throws Exception { + var source = new DriverManagerDataSource("jdbc:h2:mem:trip-cost-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", ""); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json LONGTEXT)"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-A','COMPLETED'),(2,'TR-B','PLANNED')"); + jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,7,'DEPART',?)", DEPARTURE); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String name : List.of("ll_fleet_trip_cost", "ll_fleet_trip_cost_history")) { + var match = Pattern.compile("(?s)CREATE TABLE " + name + " \\(.*?;").matcher(schema); + assertThat(match.find()).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + } + var config = new MybatisConfiguration(); config.addMapper(FleetTripMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); + repository = spy(new FleetTripCostRepository(jdbc)); + var proxy = new ProxyFactory(new FleetTripCostService(mapper, repository, json)); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (FleetTripCostService) proxy.getProxy(); + } + private static FleetTripCostModels.Draft draft(String currency, String amount) { + return new FleetTripCostModels.Draft("FUEL", currency, new BigDecimal(amount), Instant.parse("2026-01-01T00:00:00Z"), null); + } + private FleetTripCostModels.Cost create(String trip, String key, String currency, String amount) { + return service.create(trip, "1", new FleetTripCostModels.Create(key, draft(currency, amount))); + } + @Test void departureOnlyWeightsConserveCentsAndNeverMixCurrencies() { + create("TR-A", "CNY-ONE", "CNY", "100.00"); create("TR-A", "USD-ONE", "USD", "0.01"); + var listing = service.list("TR-A", PAGE); + assertThat(listing.summary().departureVersion()).isEqualTo(7); + assertThat(listing.summary().totals()).hasSize(2); + var cny = listing.summary().totals().get(0); + assertThat(cny.ownCost()).isEqualByComparingTo("66.67"); assertThat(cny.externalCost()).isEqualByComparingTo("33.33"); + for (var total : listing.summary().totals()) assertThat(total.allocations().stream().map(FleetTripCostModels.Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(total.directCost()); + assertThat(cny.allocations()).extracting(FleetTripCostModels.Allocation::businessNo).containsExactly("DS-A", "FB-B"); + assertThat(jdbc.queryForList("SELECT status FROM ll_fleet_trip WHERE id=1", String.class)).containsExactly("COMPLETED"); + assertThat(jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_trip_history WHERE trip_id=1", String.class)).isEqualTo(DEPARTURE); + } + @Test void batchSummaryReusesTheSameAmountsWithoutPerTripQueriesAndDistinguishesMissingTrips() { + create("TR-A", "BATCH-COST", "CNY", "100.00"); + var expected = service.list("TR-A", PAGE).summary(); + clearInvocations(repository); + var result = service.summaries(List.of("TR-A", "TR-B", "MISSING", "TR-A")); + assertThat(result.keySet()).containsExactly("TR-A", "TR-B"); + assertThat(result.get("TR-A")).isEqualTo(expected); + assertThat(result.get("TR-B").totals()).isEmpty(); + verify(repository, never()).departure(anyLong()); + verify(repository, never()).totals(anyLong()); + verify(repository, times(1)).trips(anyCollection()); + verify(repository, times(1)).departures(anyCollection()); + verify(repository, times(1)).totals(anyCollection()); + assertThat(service.summaries(List.of())).isEmpty(); + assertThatThrownBy(() -> service.summaries(Collections.nCopies(101, "TR-A"))).hasMessageContaining("最多100"); + assertThatThrownBy(() -> service.summaries(List.of(" "))).hasMessageContaining("有效车次"); + } + @Test void createReplayAndConcurrentDuplicateCannotCreateTwoCosts() throws Exception { + var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5")); + var b = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5")); + assertThat(a.get(10, TimeUnit.SECONDS).costNo()).isEqualTo(b.get(10, TimeUnit.SECONDS).costNo()); + } finally { executor.shutdownNow(); } + assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1); + assertThatThrownBy(() -> create("TR-A", "SAME-KEY", "CNY", "6")).hasMessageContaining("其他成本内容"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history", Integer.class)).isEqualTo(1); + } + @Test void correctionRetainsOriginalAggregateAndRejectsOtherTripsAndStaleVersions() { + var original = create("TR-A", "COST-ONE", "USD", "30"); + var request = new FleetTripCostModels.Update(0L, draft("USD", "60"), "核对实际费用"); + assertThatThrownBy(() -> service.update("TR-B", original.costNo(), "1", request)).hasMessageContaining("不存在"); + service.update("TR-A", original.costNo(), "1", request); + assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", request)).hasMessageContaining("已变化"); + var history = service.history("TR-A", original.costNo(), PAGE).records(); + assertThat(history).hasSize(2); assertThat(history.get(0).reason()).isEqualTo("核对实际费用"); + assertThat(history.get(0).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("60"); + assertThat(history.get(1).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("30"); + } + @Test void equivalentMoneyAndEmptyRemarksReplayWithoutAnotherCostOrHistory() { + var original = create("TR-A", "EQUIVALENT-KEY", "USD", "30"); + var equivalent = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("30.00"), original.details().incurredAt(), " "); + var replay = service.create("TR-A", "1", new FleetTripCostModels.Create("EQUIVALENT-KEY", equivalent)); + assertThat(replay.costNo()).isEqualTo(original.costNo()); + var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, equivalent, "仅说明不同")); + assertThat(unchanged.recordVersion()).isZero(); + assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(1); + assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1); + service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null)); + assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null))) + .hasMessageContaining("已变化"); + } + @Test void millisecondStorageAndReplayingCreationAfterCorrectionPreserveTheCurrentCost() { + var input = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("12.50"), Instant.parse("2026-01-01T00:00:00.123456Z"), " 凭据说明 "); + var command = new FleetTripCostModels.Create("PRECISE-TIME-KEY", input); + var original = service.create("TR-A", "1", command); + assertThat(original.details().incurredAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00.123Z")); + assertThat(original.details().remark()).isEqualTo("凭据说明"); + var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, input, null)); + assertThat(unchanged.recordVersion()).isZero(); + service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "20"), null)); + var replay = service.create("TR-A", "1", command); + assertThat(replay.recordVersion()).isEqualTo(1); + assertThat(replay.details().currency()).isEqualTo("CNY"); + assertThat(replay.details().amount()).isEqualByComparingTo("20"); + assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(2); + } + @Test void auditFailureRollsBackCostAndAmountCorrection() { + var original = create("TR-A", "ORIGINAL", "CNY", "30"); + doThrow(new IllegalStateException("history unavailable")).when(repository).audit(anyLong(), any(), anyString(), anyString(), anyString(), anyString()); + assertThatThrownBy(() -> create("TR-A", "REJECTED", "CNY", "90")).hasMessageContaining("history unavailable"); + assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "70"), null))).hasMessageContaining("history unavailable"); + var list = service.list("TR-A", PAGE); + assertThat(list.page().total()).isEqualTo(1); assertThat(list.page().records().get(0).recordVersion()).isZero(); + assertThat(list.summary().totals().get(0).directCost()).isEqualByComparingTo("30"); + } + @Test void noDepartureAndNoCostsAreUnknownAndDifferentStopsPreventInventedAllocations() { + assertThat(service.list("TR-B", PAGE).summary().totals()).isEmpty(); + create("TR-B", "BEFORE-DEPART", "USD", "0"); + var before = service.list("TR-B", PAGE).summary(); + assertThat(before.allocationBlockReason()).contains("尚无实际发车"); assertThat(before.totals().get(0).ownCost()).isNull(); + jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=1", DEPARTURE.replace("\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":1", "\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":2")); + create("TR-A", "MULTI-STOP", "USD", "50"); + var summary = service.list("TR-A", PAGE).summary(); + assertThat(summary.allocationBlockReason()).contains("卸货站不同"); assertThat(summary.totals().get(0).allocations()).isEmpty(); + assertThat(summary.totals().get(0).directCost()).isEqualByComparingTo("50"); + } + @Test void mismatchedExternalOwnerOrReceiptCannotBeClassifiedAsOwnCargo() throws Exception { + var wrongCustomer = json.readValue(DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":\"3\",\"status\""), FleetTripModels.AuditSnapshot.class); + assertThat(FleetTripCostBasis.from(wrongCustomer).blockReason()).contains("归属未核平"); + var wrongQuantity = json.readValue(DEPARTURE.replace("\"receivedPackages\":2", "\"receivedPackages\":3"), FleetTripModels.AuditSnapshot.class); + assertThat(FleetTripCostBasis.from(wrongQuantity).blockReason()).contains("总量"); + } + + @Test void activeUnboundExternalCargoCannotSilentlyBecomeOwnCargo() throws Exception { + for (var status : List.of("CONFIRMED", "RECEIVED", "UNKNOWN")) { + var snapshot = json.readValue(DEPARTURE.replace("\"status\":\"BOUND\"", "\"status\":\"" + status + "\""), FleetTripModels.AuditSnapshot.class); + assertThat(FleetTripCostBasis.from(snapshot).blockReason()).contains("实收及排车依据"); + } + } + + @Test void boundExternalCargoRequiresIdentityAndUniqueReceiptPallets() throws Exception { + for (var malformed : List.of( + DEPARTURE.replace("\"bookingNo\":\"FB-B\"", "\"bookingNo\":null"), + DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":null,\"status\""), + DEPARTURE.replace("\"status\":\"BOUND\",\"dispatchNo\":\"DS-B\"", "\"status\":\"BOUND\",\"dispatchNo\":null"))) { + assertThat(FleetTripCostBasis.from(json.readValue(malformed, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("实收及排车依据"); + } + var duplicate = DEPARTURE.replace("\"warehousePalletId\":\"P2\",\"packages\":2}", + "\"warehousePalletId\":\"P2\",\"packages\":2},{\"warehousePalletId\":\"P2\",\"packages\":2}"); + assertThat(FleetTripCostBasis.from(json.readValue(duplicate, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("归属未核平"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index dfc5a42c9..9f2b847da 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -476,6 +476,9 @@ class FleetTripPersistenceTest { assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED"); assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1); assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); + assertThat(result.departedAt().getNano() % 1_000_000).isZero(); + assertThat(service.detail(number).trip().departedAt()).isEqualTo(result.departedAt()); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().trip().departedAt()).isEqualTo(result.departedAt()); assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue(); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java index 08773e1f1..baad0af40 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java @@ -13,7 +13,7 @@ import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */ +/** Exercises the production SQL with H2 adapters for MySQL JSON scalar functions. */ public class VehicleFulfillmentQueryTest { private JdbcTemplate jdbc; private VehicleFulfillmentMapper mapper; @@ -22,19 +22,37 @@ public class VehicleFulfillmentQueryTest { private static final ObjectMapper JSON = new ObjectMapper(); public static String jsonExtract(String source, String path) throws Exception { if (source == null) return null; + if ("$".equals(path)) return source; var value = JSON.readTree(source); for (String field : path.substring(2).split("\\.")) value = value.path(field); return value.isMissingNode() ? null : value.toString(); } public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); } + public static String jsonQuote(String value) throws Exception { return JSON.writeValueAsString(value); } + public static int jsonContains(String source, String candidate, String path) throws Exception { + String array = jsonExtract(source, path); + if (array == null) return 0; + var expected = JSON.readTree(candidate); + for (var entry : JSON.readTree(array)) if (entry.equals(expected)) return 1; + return 0; + } + public static String jsonSet(String source, String path, String value) throws Exception { + var document = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(source); + document.set(path.substring(2), JSON.readTree(value)); + return document.toString(); + } @BeforeEach void setup() throws Exception { var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); jdbc = new JdbcTemplate(dataSource); jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'"); jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'"); + jdbc.execute("CREATE ALIAS JSON_QUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonQuote'"); + jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'"); + jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'"); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))"); jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)"); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); @@ -104,4 +122,24 @@ public class VehicleFulfillmentQueryTest { assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在"); assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在"); } + @Test void handoverAssociatesOnlyActuallyCarriedDeliveriesAndScopesChangeHistory() throws Exception { + history(0,"DEPART","8","18"); history(1,"HANDOVER","9","19"); + jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'"); + String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"), + "carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")), + "cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12"))))); + jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot); + assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records()) + .extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22"); + assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> { + assertThat(change.relationship()).isEqualTo("REPLACED_IN"); + assertThat(change.dispatchNos()).containsExactly("DP-12"); + }); + jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22"); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1); + // Matching delivery numbers alone must never associate another customer's document. + jdbc.update("UPDATE ll_delivery_order SET customer_id='CUSTOMER-A' WHERE id=22"); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index ae25afa78..f87f6e648 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -16,7 +16,9 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class OutboxLeaseDatabaseTest { - @Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"OverseasContainerUnloaded", "CompanyVehicleCostSubmitted"}) + void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff(String eventType) throws Exception { var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var jdbc = new JdbcTemplate(dataSource); jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); @@ -30,9 +32,9 @@ class OutboxLeaseDatabaseTest { ReflectionTestUtils.setField(repository, "mapper", mapper); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); - jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3)); - assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); - assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", eventType, now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); assertThat(repository.claim(1L, "new", now)).isFalse(); var next = now.plusSeconds(10); @@ -40,7 +42,7 @@ class OutboxLeaseDatabaseTest { () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1); assertThat(repository.claim(1L, "early", now)).isFalse(); - assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", next, + assertThat(journal.begin("new", "lastleg-service", eventType, "1", null, "consumer", next, () -> repository.claim(1L, "new", next))).isTrue(); assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, () -> repository.dead(1L, "old", 12, "late"))).isFalse(); 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 a2dde1a0e..295791f22 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -55,6 +55,11 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", + "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", + "reason": "费用适配器仅实现Fleet公司成本换算与Outbox端口,Fleet不读取费用模块或Outbox持久化实现;同事务发布且不新增package循环。" + }, { "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", "target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway", diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index ab7582ab3..c3a120b00 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 145]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 152]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,45 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|145) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|152) AS initialized_tables;$/) + } +}) + +test('车次直接成本按车次幂等并保留独立币种与不可变更正历史', () => { + const cost = table(overseas, 'baiyue_lastleg', 'll_fleet_trip_cost') + const history = table(overseas, 'baiyue_lastleg', 'll_fleet_trip_cost_history') + assert.match(cost, /UNIQUE KEY uk_fleet_trip_cost_request\(trip_id,created_by,request_key\)/i) + assert.match(cost, /FOREIGN KEY\(trip_id\) REFERENCES ll_fleet_trip\(id\)/i) + assert.match(cost, /CHECK\(amount>=0\)/i) + assert.match(cost, /CHECK\(currency IN \('CNY','USD'\)\)/i) + assert.match(history, /UNIQUE KEY uk_fleet_trip_cost_history\(cost_id,record_version\)/i) + assert.match(history, /FOREIGN KEY\(cost_id\) REFERENCES ll_fleet_trip_cost\(id\)/i) + assert.match(history, /snapshot_json JSON NOT NULL/i) +}) + +test('车辆报价与实测记录保留业务去重和不可变版本', () => { + const quote = table(overseas, 'baiyue_lastleg', 'll_fleet_quote') + assert.match(quote, /UNIQUE KEY uk_fleet_quote_no \(quote_no\)/i) + assert.match(quote, /UNIQUE KEY uk_fleet_quote_natural \(natural_key\)/i) + assert.match(quote, /UNIQUE KEY uk_fleet_quote_create \(created_by,create_key\)/i) + assert.match(table(overseas, 'baiyue_lastleg', 'll_fleet_quote_history'), /UNIQUE KEY uk_fleet_quote_history_version \(quote_id,record_version\)/i) + const measured = table(overseas, 'baiyue_lastleg', 'll_fleet_trip_operations_record') + assert.match(measured, /UNIQUE KEY uk_fleet_operations_version \(trip_no,record_version\)/i) + assert.match(measured, /UNIQUE KEY uk_fleet_operations_request \(trip_no,operator_id,request_key\)/i) +}) + +test('公司车辆费用隔离于客户账务并约束实际付款金额', () => { + const cost = table(cn, 'baiyue_finance', 'fin_vehicle_cost') + assert.doesNotMatch(cost, /\b(customer_id|tenant_id)\b/i) + assert.match(cost, /subject_id BIGINT UNSIGNED NOT NULL/i) + assert.match(cost, /UNIQUE KEY uk_fin_vehicle_cost_source \(subject_id,source_type,source_no\)/i) + assert.match(cost, /CHECK \(amount>0 AND paid_amount>=0 AND paid_amount<=amount\)/i) + assert.match(table(cn, 'baiyue_finance', 'fin_vehicle_cost_payment'), /FOREIGN KEY \(cost_id\) REFERENCES fin_vehicle_cost\(id\)/i) + assert.match(table(cn, 'baiyue_finance', 'fin_vehicle_cost_revision'), /UNIQUE KEY uk_fin_vehicle_cost_request \(operator_id,action,request_key\)/i) + const admin = roleAuthorities(cn, 'PLATFORM_ADMIN'), merchant = roleAuthorities(cn, 'MERCHANT_OWNER') + for (const action of ['list', 'approve', 'void', 'pay', 'upload']) { + assert.ok(admin.has(`platform:finance:vehicle-cost:${action}`)) + assert.ok(!merchant.has(`platform:finance:vehicle-cost:${action}`)) } }) @@ -56,7 +94,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 392, iam_role_permission: 573, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 411, iam_role_permission: 592, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -84,9 +122,17 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) } - for (const action of ['list', 'create', 'edit', 'assign', 'cancel']) { + for (const action of ['list', 'create', 'edit', 'assign', 'cancel', 'cost-create', 'cost-edit']) { assert.ok(admin.has(`platform:fleet:trip:${action}`)) } + for (const action of ['list', 'create', 'edit', 'status', 'delete', 'import', 'export']) { + assert.ok(admin.has(`platform:fleet:quote:${action}`)) + assert.ok(!merchant.has(`platform:fleet:quote:${action}`)) + } + for (const action of ['list', 'export', 'edit']) { + assert.ok(admin.has(`platform:fleet:operations:${action}`)) + assert.ok(!merchant.has(`platform:fleet:operations:${action}`)) + } assert.ok(!permissions.some(row => row.permission_code === 'platform:order:complete'), '订单完成必须由可信履约回写产生,不能恢复人工完成权限') for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold', -- 2.54.0 From a6fd0ba3c52e36b3e194603450949fd438918026 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 13:32:55 +0800 Subject: [PATCH 096/176] feat(fleet): bind quote versions and reconcile actual return quantities --- deploy/sql/README.md | 3 + deploy/sql/海外区域统一增量-MySQL8.sql | 11 +- lastleg-service/AGENTS.md | 5 +- .../delivery/entity/DeliveryOrder.java | 1 + .../mapper/DeliveryOperationMapper.java | 24 ++++ .../model/DeliveryCargoQuantities.java | 32 ++++++ .../model/DeliveryReturnReceiptFact.java | 6 + .../delivery/model/DeliveryStatusPolicy.java | 2 +- .../DeliveryCompletedOutboxService.java | 13 ++- .../service/DeliveryDocumentService.java | 29 ++++- .../dispatch/fleet/DispatchFleetAdapter.java | 32 +++++- .../exceptionorder/entity/ExceptionOrder.java | 1 + .../entity/ExceptionReturnItem.java | 1 + .../mapper/ExceptionOrderMapper.java | 2 +- .../mapper/ExceptionReturnItemMapper.java | 12 ++ .../service/ExceptionOrderService.java | 92 ++++++++++++--- .../service/ReturnReceiptPolicy.java | 37 ++++++ .../fleet/driver/FleetWarehouseClient.java | 6 + .../exception/FleetHandoverPdaQuery.java | 3 +- .../fleet/exception/FleetHandoverPolicy.java | 19 +++- .../quote/FleetQuoteCandidateModels.java | 28 +++++ .../quote/FleetQuoteCandidateRepository.java | 26 +++++ .../quote/FleetQuoteCandidateService.java | 70 ++++++++++++ ...PlatformFleetQuoteCandidateController.java | 16 +++ .../fleet/trip/FleetBookingEntity.java | 1 + .../fleet/trip/FleetBookingModels.java | 13 ++- .../fleet/trip/FleetBookingQuoteContext.java | 62 ++++++++++ .../fleet/trip/FleetBookingReservations.java | 4 +- .../fleet/trip/FleetBookingService.java | 34 +++++- .../fleet/trip/FleetCompletionModels.java | 2 +- .../trip/FleetTripCompletionService.java | 4 +- .../trip/PlatformFleetBookingController.java | 4 + .../src/main/resources/db/lastleg.sql | 11 +- .../openapi/delivery-documents-v1.yaml | 1 + .../resources/openapi/fleet-handovers-v1.yaml | 11 ++ .../openapi/fleet-quote-candidates-v1.yaml | 82 ++++++++++++++ .../resources/openapi/fleet-trips-v1.yaml | 48 +++++++- .../openapi/overseas-business-v1.yaml | 6 +- .../model/DeliveryCargoQuantitiesTest.java | 34 ++++++ .../model/DeliveryStatusPolicyTest.java | 2 +- .../DeliveryCompletedOutboxServiceTest.java | 14 +++ .../service/DeliveryDocumentServiceTest.java | 26 ++++- ...DeliveryReturnEvidencePersistenceTest.java | 49 ++++++++ ...xceptionOrderDispatchReturnSourceTest.java | 2 + .../service/ExceptionOrderLifecycleTest.java | 79 +++++++++++++ .../ExceptionReturnConcurrencyTest.java | 68 +++++++++++ .../service/ReturnReceiptPolicyTest.java | 30 +++++ .../exception/FleetHandoverPdaQueryTest.java | 4 +- .../exception/FleetHandoverPolicyTest.java | 6 +- .../FleetHandoverTransactionTest.java | 2 +- .../exception/FleetTransitEvidenceTest.java | 40 ++++++- .../FleetQuoteCandidatePersistenceTest.java | 53 +++++++++ .../quote/FleetQuoteCandidateServiceTest.java | 67 +++++++++++ .../trip/FleetBookingQuoteContextTest.java | 61 ++++++++++ .../fleet/trip/FleetTripApiContractTest.java | 15 ++- .../trip/FleetTripAuthorizationTest.java | 13 +++ .../fleet/trip/FleetTripPersistenceTest.java | 107 ++++++++++++++++-- ...hase5ExceptionVisitSchemaContractTest.java | 6 +- .../Phase5OverseasUpgradeSqlContractTest.java | 3 +- .../initialization-baseline-contract.test.mjs | 3 + 60 files changed, 1363 insertions(+), 75 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d552636ee..b17f7ea9a 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -13,6 +13,9 @@ - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 - 车辆成本、报价、出车实测及在途交接表对应Lastleg模块基线;公司车辆费用三表对应Finance模块基线。公司财务权限源为`iam/src/main/resources/db/fleet-finance-permissions.sql`,已合入国内统一种子,不以模块文件代替完整空库初始化。 +- 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。 +- 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。 +- 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 677e05e0b..02edb3802 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -275,6 +275,9 @@ CREATE TABLE ll_delivery_order ( version bigint NOT NULL DEFAULT '0' COMMENT '派送单乐观锁版本', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + signed_packages INT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量', + CONSTRAINT chk_delivery_signed_packages CHECK(signed_packages IS NULL OR (signed_packages>=0 AND signed_packages<=package_count)), + PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), @@ -684,9 +687,11 @@ CREATE TABLE ll_exception_order ( version bigint NOT NULL DEFAULT '0' COMMENT '异常订单乐观锁版本', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + return_context_json JSON NULL COMMENT '退回异常创建前的派送及尾程状态快照', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exception_no (customer_id,exception_no), - UNIQUE KEY uk_ll_exception_delivery_return (customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), KEY idx_ll_exception_list (customer_id,status,exception_at), KEY idx_ll_exception_tail (customer_id,last_mile_order_no), KEY idx_ll_exception_inbound (customer_id,inbound_code) @@ -717,6 +722,9 @@ CREATE TABLE ll_exception_return_item ( max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限', approved_return_packages bigint NOT NULL COMMENT '批准退回箱数', created_at datetime(3) NOT NULL COMMENT '创建时间', + received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', + CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), + PRIMARY KEY (id), UNIQUE KEY uk_ll_exception_return_source (customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), KEY idx_ll_exception_return_order (customer_id,exception_order_id) @@ -2735,6 +2743,7 @@ CREATE TABLE ll_fleet_external_booking ( customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键', destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称', agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收', diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 9bcf01a0b..d2f0489c0 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -61,7 +61,7 @@ | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | | 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` | | 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` | -| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;OpenAPI `fleet-vehicle-records-v1.yaml`;逐车及逐类型隔离,保养加油显式单位,事件保留项目/地点/处理人;申报费用须有币种,处理完成不代表付款,当前无财务关联只返回UNLINKED。创建/上传幂等,更正保留版本及历史,不自动变更主档里程、车辆状态或财务 | +| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 | | 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 | | 调度与理货审核 | `FleetTripService.reviewDispatch`、`FleetTripExecutionService.reviewLoading`;每张客户排车单先调度通过,整车确认实载后理货通过。`FleetDispatchBindingGuard.requirePickingApproved` 在新拣货/完成前核对当前单审核;已成功操作的同键重放保留原结果 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | @@ -77,6 +77,7 @@ - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。 +- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 - 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 @@ -87,7 +88,7 @@ - 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 共享车次关联取排车单自身客户与起运仓;排车有效关联唯一,多客户不合并库存归属。`FleetDispatchBindingGuard` 在子单行锁内阻止独立编辑/移托/取消及绕过车次协调单独发车;转派送的新建请求不能覆盖车次车辆司机,已成功转换的重放继续返回既有单据。PDA详情同步返回车次统一发车阻断原因;关联与改派通过 `FleetDispatchGateway` 同事务同步尚未执行的派送快照。已拣货禁止直接解除关联或变更路线;发车前可统一换车/司机或保持路线调整时间,保留拣货事实与到达时间,重验司机任务覆盖及替换车辆对已确认装载的容量,成功后调度审核、理货审核及原装载确认失效;仅修改备注保留审核与装载。已出库/发车不允许此类修改。 - 实物装载按仓库托盘ID去重计量,客户货量与物理占位分别保存;重量/体积为明确确认的实际值,不读取计费材积重回填。路线卸货站的 `destinationKey` 必须匹配原货物目的地,同实托不同目的地先拆托。装载确认保存来源hash、版本、操作人和历史;改计划/改派/关联变化使确认失效。整车发车在同一Lastleg事务中复核所有子单、车辆占用及司机证照,调用原子单出库命令后统一提交 `IN_TRANSIT` 和幂等结果;任一失败全车回滚,`DEPARTING`只在事务内部使用。PDA列表按车次复用发车门禁查询,再按当前客户排车单审核返回canPick/pickBlockReason;客户A通过不能代替客户B通过。重新确认装载或重新调度审核使理货审核待审,审核历史不删除;过期前30天提醒与阻断分别返回。 -- 车次货物结案只消费原派送单事实:已签收须有signedAt,已退回须有RETURN_COMPLETED操作记录;拒收、待退回和取消不能冒充完成。完成时间为本车最后一票实际终态时间,不取最近编辑时间;完成不改客户状态、不生成费用、不释放占用。车辆与司机分别结束实际任务并保留地点/时间,车辆状态与资源释放同事务提交;司机证照过期不阻止结束旧任务,但不恢复其新任务资格。原释放请求重放不影响资源后来已分配的新车次。 +- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 2197f002c..a8f275028 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -55,6 +55,7 @@ public class DeliveryOrder { private LocalDateTime dispatchedAt; private LocalDateTime expectedArrivalAt; private LocalDateTime signedAt; + private Integer signedPackages; private String remark; private String idempotencyKey; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java index cbb6e2f31..f6ccb1afa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.delivery.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -10,6 +11,29 @@ import java.util.List; @Mapper public interface DeliveryOperationMapper extends BaseMapper { + + @Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " + + "l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " + + "JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + + "JOIN ll_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " + + "JOIN ll_outbound_pallet_item oi ON oi.customer_id=d.customer_id AND oi.outbound_order_id=d.outbound_order_id AND oi.id=i.outbound_pallet_item_id " + + "JOIN ll_inventory_ledger l ON l.customer_id=e.customer_id AND l.business_order_no=e.exception_no " + + "AND l.outbound_order_no=d.outbound_no AND l.business_type='RETURN_STOCK_IN' AND l.delta_packages>0 " + + "AND l.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id) " + + "LEFT JOIN ll_dispatch_item di ON di.customer_id=d.customer_id AND di.dispatch_order_id=d.dispatch_order_id " + + "AND di.warehouse_pallet_id=oi.warehouse_pallet_id " + + "AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " + + "ON a.customer_id=p.customer_id AND a.pick_id=p.id " + + "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " + + "AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " + + "WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} ORDER BY l.occurred_at,l.id") + List returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " + + "AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING')") + long activeReturns(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); + @Select("SELECT MAX(created_at) FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} AND operation_node='RETURN_COMPLETED'") java.time.LocalDateTime returnedAt(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); @Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java new file mode 100644 index 000000000..51e4673db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import java.util.HashSet; +import java.util.List; + +/** Quantity accounting for the original journey; later post-signature returns belong to another movement. */ +public record DeliveryCargoQuantities(int shipped, int signed, int returned, int remaining) { + public static DeliveryCargoQuantities from(DeliveryOrder order, List receipts) { + if (order.getPackageCount() == null || order.getPackageCount() <= 0) throw invalid("派送缺少实发数量"); + var ids = new HashSet(); + long received = 0; + for (var receipt : receipts) { + if (receipt.ledgerId() == null || !ids.add(receipt.ledgerId()) || receipt.packages() <= 0 || receipt.receivedAt() == null) + throw invalid("退回实际入账流水缺失或来源映射重复"); + received = Math.addExact(received, receipt.packages()); + } + int shipped = order.getPackageCount(); + if (received > shipped) throw invalid("退回实收超过原实发数量"); + if (order.getSignedAt() != null) { + Integer signed = order.getSignedPackages(); + long beforeSignature = receipts.stream().filter(receipt -> !receipt.receivedAt().isAfter(order.getSignedAt())).mapToLong(DeliveryReturnReceiptFact::packages).sum(); + if (signed == null || signed <= 0 || signed > shipped || beforeSignature < shipped - signed) + throw invalid("签收数量快照缺失或与实际退回不守恒"); + // Do not re-open or reclassify signed cargo when another return is later received. + return new DeliveryCargoQuantities(shipped, signed, shipped - signed, 0); + } + if (order.getSignedPackages() != null) throw invalid("签收数量缺少签收时间"); + return new DeliveryCargoQuantities(shipped, 0, Math.toIntExact(received), shipped - Math.toIntExact(received)); + } + private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java new file mode 100644 index 000000000..917bbe869 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.delivery.model; + +import java.time.LocalDateTime; + +/** Immutable actual receipt, attributed to the original dispatch item and physical pallet. */ +public record DeliveryReturnReceiptFact(Long ledgerId, Long dispatchItemId, Long palletId, long packages, LocalDateTime receivedAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java index 1b6b01368..6ffb102b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -31,7 +31,7 @@ public final class DeliveryStatusPolicy { case "WAIT_DELIVERY" -> List.of( "START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN"); - case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); + case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN"); default -> List.of(); }; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 9d6dc5565..954dd8d36 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -33,9 +33,13 @@ public class DeliveryCompletedOutboxService { @Resource private LastlegEventOutboxMapper outbox; @Resource private ObjectMapper objectMapper; - public void publish(DeliveryOrder delivery, List orders, Instant completedAt) { + public enum Publication { PUBLISHED, NO_DOMESTIC_SOURCE, UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION, MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION } + + public Publication publish(DeliveryOrder delivery, List orders, Instant completedAt) { Objects.requireNonNull(delivery, "delivery"); Objects.requireNonNull(completedAt, "completedAt"); + // The domestic consumer marks the whole inbound order DELIVERED (signed), not merely transport closed. + // A mixed return/signature must not publish that full-signature fact. Map bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.of() : orders) { // Only orders projected from the domestic first-leg domain need an @@ -59,7 +63,14 @@ public class DeliveryCompletedOutboxService { throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突"); } } + if (bySourceInbound.isEmpty()) return Publication.NO_DOMESTIC_SOURCE; + if (delivery.getPackageCount() == null || delivery.getPackageCount() <= 0 || delivery.getSignedPackages() == null + || delivery.getSignedPackages() <= 0 || delivery.getSignedPackages() > delivery.getPackageCount()) + return Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION; + if (!Objects.equals(delivery.getSignedPackages(), delivery.getPackageCount())) + return Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION; for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt); + return Publication.PUBLISHED; } private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index d888a6b5f..295c44f16 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -86,12 +86,18 @@ public class DeliveryDocumentService { }).toList(); LocalDateTime now = LocalDateTime.now(); boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty(); - boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus()); + boolean autoCompleted = receiptSaved && order.getSignedAt() == null && !"DELIVERED".equals(order.getStatus()); + int signedPackages = 0; if (autoCompleted) { - if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) throw conflict("当前派送状态不允许通过签收文件完成"); + if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus())) + throw conflict("当前派送状态不允许通过签收文件完成"); // A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible. var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo()); if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送"); + if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); + try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); } + catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } + if (signedPackages <= 0) throw conflict("本次派送已无待签收货物"); } order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim()); order.setDocumentsJson(write(categories)); @@ -100,11 +106,23 @@ public class DeliveryDocumentService { if (autoCompleted) { order.setStatus("DELIVERED"); order.setSignedAt(now); + order.setSignedPackages(signedPackages); } orders.updateById(order); if (autoCompleted) { List completedOrders = completeRelatedOrders(order, now); - deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); + var publication = deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); + if (publication == DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION + || publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION) { + DeliveryOperation pending = new DeliveryOperation(); + pending.setCustomerId(customerId); pending.setDeliveryOrderId(order.getId()); pending.setOperatorId(operatorId); + pending.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); + pending.setResult(publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION + ? "签收数量未确认或无效,国内整票签收事件未发布,须核对真实实签量后继续回写" + : "原实发" + order.getPackageCount() + "箱,本次签收" + order.getSignedPackages() + + "箱,其余已退回;国内整票签收事件未发布,须按来源入仓单核对签收与退回处置后继续回写"); + pending.setCreatedAt(now); operations.insert(pending); + } customerVisits.ensureForCompletedDelivery(order, now); } Detail result = detail(order, false); @@ -156,7 +174,8 @@ public class DeliveryDocumentService { // Preserve the outbound leg when receipt completion closes delivery. // Sub-order status is a composite fact; replacing it with DELIVERY alone // makes a successfully departed outbound appear unfinished or disappear. - order.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED"); + order.setSubOrderStatus(delivery.getSignedPackages().equals(delivery.getPackageCount()) + ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); order.setUpdatedAt(now); lastlegOrders.updateById(order); } @@ -167,7 +186,7 @@ public class DeliveryDocumentService { operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); - operation.setResult("签收文件保存成功,派送单自动完成"); + operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱"); operation.setCreatedAt(now); operations.insert(operation); } private String hash(Object value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index 0c5a97102..fdabb5351 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities; import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; @@ -159,17 +160,26 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); var rows = outbound == null ? java.util.List.of() : lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); - var outcomes = rows.stream().map(row -> new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(), + if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum() + != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount()))) + return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物"); + var outcomes = rows.stream().map(row -> { + var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId())); + return new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(), "PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()), originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0), originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) - ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null)).toList(); + ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null, + quantities.signed(), quantities.returned(), quantities.remaining()); + }).toList(); String reason = null; if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成"; else if (outcomes.isEmpty()) reason = "缺少对应派送单"; else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物"; - else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null)) + else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || row.remainingPackages() != 0 + || row.signedPackages() + row.returnedPackages() != row.packages() + || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null)) reason = "所有派送单须已签收,或完成真实退回入仓"; return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason); } @@ -194,17 +204,25 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) || !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId()) || row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null - || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) + || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) throw conflict("派送货物归属或执行状态与原始发运不一致"); Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null; if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null) throw conflict("派送终态缺少真实签收或退回完成证据"); + var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()); + var quantities = quantities(row, receipts); + if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物"); String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null, row.getDeliveryAddress(), row.getPostalCode()); if (key == null) throw conflict("派送单缺少真实卸货目的地"); var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(), - dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal); + dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal, + originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> { + if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属"); + return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo( + receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt())); + }).toList()); facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(), new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone()))); packages = Math.addExact(packages, row.getPackageCount()); @@ -236,6 +254,10 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { } private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); } + private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List receipts) { + try { return DeliveryCargoQuantities.from(row, receipts); } + catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } + } private static boolean originallySigned(DeliveryOrder order) { return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java index 653b11372..48dde881a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -31,6 +31,7 @@ public class ExceptionOrder { private Integer packageCount; private String returnDirection; private String returnLogisticsNo; + private String returnContextJson; private String handlingAction; private String handlingResult; private String evidenceFileIdsJson; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java index 24bf07152..6de63567f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -19,5 +19,6 @@ public class ExceptionReturnItem { private String skuCode; private Long maxReturnPackages; private Long approvedReturnPackages; + private Long receivedPackages; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java index be96b379c..32659e2b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ExceptionOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " + - "AND exception_type='RETURN' LIMIT 1") + "AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING') LIMIT 1") ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java index 0b29a66e7..c7cf170dd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -13,4 +13,16 @@ public interface ExceptionReturnItemMapper extends BaseMapper findByException(@Param("customerId") String customerId, @Param("exceptionOrderId") Long exceptionOrderId); + + @Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " + + "JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " + + "WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId}") + long receivedByDelivery(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " + + "JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " + + "WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId} " + + "AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}") + long receivedBySource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId, + @Param("itemId") Long itemId, @Param("positionId") Long positionId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 4bb787b6c..d6da45553 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -130,7 +130,8 @@ public class ExceptionOrderService { public List returnCandidates(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); - if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN") + && !"RETURN_PENDING".equals(delivery.getStatus())) { throw conflict("当前派送状态不允许标记退回"); } return returnCandidates(delivery); @@ -157,19 +158,13 @@ public class ExceptionOrderService { request.skuCode(), null, null, "MANUAL", operatorId, operatorName); } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, ExceptionOrderRequests.MarkReturn request) { String requestHash = hash(request); ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); if (replay != null) return replayCreated(replay, requestHash); - DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); - if (delivery == null) throw notFound("派送单不存在"); - if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { - throw conflict("当前派送状态不允许标记退回"); - } - ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); - if (existing != null) throw conflict("该派送单已存在退回异常单"); + DeliveryOrder delivery = lockForReturn(customerId, deliveryNo); List candidates = returnCandidates(delivery); ExceptionOrderResponses.Relation selected = candidates.stream() @@ -198,6 +193,7 @@ public class ExceptionOrderService { item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode()); item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); + item.setReceivedPackages(0L); item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item); } @@ -222,6 +218,17 @@ public class ExceptionOrderService { return created; } + /** Caller keeps the delivery row locked until return creation commits. */ + DeliveryOrder lockForReturn(String customerId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) + throw conflict("当前派送状态不允许标记退回"); + if (exceptionOrders.findDeliveryReturn(customerId, delivery.getId()) != null) + throw conflict("该派送单已存在退回异常单"); + return delivery; + } + @Transactional public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId, String exceptionNo, @@ -247,6 +254,7 @@ public class ExceptionOrderService { ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim()); if (replay != null) return replayResult(replay, requestHash); ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + restoreCancelledReturn(order); LocalDateTime now = LocalDateTime.now(); order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason())); order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); @@ -266,29 +274,42 @@ public class ExceptionOrderService { ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); ReturnedInventoryRestoreResponse restored = null; + List stored = List.of(); if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { - List stored = returnItems.findByException(customerId, order.getId()); + var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送单不是退回处理中状态"); + stored = returnItems.findByException(customerId, order.getId()); if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); + Map received = ReturnReceiptPolicy.quantities(stored, request.receipts()); restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(), order.getOverseasWarehouseId(), order.getInboundOrderId(), - stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem( + stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem( item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(), - Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(), + item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(), request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), request.evidenceFileIds().stream().map(String::valueOf).toList())); + if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum()) + throw conflict("退回库存实际入账数量与本次实收不一致"); + for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) { + item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(item.getId())); + returnItems.updateById(item); + } } else if (request.receipts() != null && !request.receipts().isEmpty()) { throw badRequest("非退回海外仓异常不能提交库存实收明细"); } LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt(); - order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult())); - order.setEvidenceFileIdsJson(write(request.evidenceFileIds())); + boolean completed = stored.isEmpty() || ReturnReceiptPolicy.complete(stored); + order.setStatus(completed ? "COMPLETED" : "PROCESSING"); order.setHandlingResult(trim(request.handlingResult())); + List evidence = new ArrayList<>(readEvidenceFileIds(order.getEvidenceFileIdsJson())); + evidence.addAll(request.evidenceFileIds()); + order.setEvidenceFileIdsJson(write(evidence.stream().distinct().toList())); order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); - order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); + order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now); order.setVersion(version(order) + 1); exceptionOrders.updateById(order); closeReturnedDelivery(order, operatorId, now); long restoredPackages = restored == null ? 0 : restored.restoredPackages(); @@ -352,6 +373,11 @@ public class ExceptionOrderService { value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName()); value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction)); value.setReturnLogisticsNo(trim(logisticsNo)); + if (delivery != null) { + LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId()); + value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(), + tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal()))); + } if (StringUtils.hasText(direction)) { value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理"); value.setHandlerId(operatorId); value.setHandlerName(operatorName); @@ -414,7 +440,9 @@ public class ExceptionOrderService { DeliveryOrder delivery) { List sources = returnSources(delivery).stream() .filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) - .map(Source::response).toList(); + .map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource( + delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId()))) + .filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList(); return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(), @@ -476,7 +504,8 @@ public class ExceptionOrderService { return returnItems.findByException(order.getCustomerId(), order.getId()).stream() .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), - Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(); + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item))) + .filter(item -> item.maxReturnPackages() > 0).toList(); } private void validateReturnItems(List available, @@ -506,10 +535,15 @@ public class ExceptionOrderService { private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) { if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + // Closing a non-warehouse case does not establish physical cargo handoff or inventory receipt. + if (!"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) return; DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) { throw conflict("关联派送单不是退货处理中状态"); } + long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId()); + if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致"); + if (received < delivery.getPackageCount()) return; delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); @@ -526,6 +560,30 @@ public class ExceptionOrderService { operation.setCreatedAt(now); deliveryOperations.insert(operation); } + private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { } + + private void restoreCancelledReturn(ExceptionOrder order) { + if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + ReturnContext previous; + try { previous = objectMapper.readValue(order.getReturnContextJson(), ReturnContext.class); } + catch (Exception exception) { throw conflict("退回异常缺少原始状态,不能猜测恢复状态"); } + if (previous == null || !Set.of("DELIVERING", "DELIVERED", "REJECTED", "RETURN_PENDING").contains(previous.deliveryStatus())) + throw conflict("退回异常原始状态无效"); + DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回"); + if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0)) + throw conflict("已有实际退回入仓,不能取消退回事实"); + delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now()); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); + if (previous.tailId() != null) { + LastlegOrder tail = lastMileOrders.selectById(previous.tailId()); + if (tail == null || !Objects.equals(tail.getCustomerId(), order.getCustomerId()) + || !"DELIVERY:RETURN_PENDING".equals(tail.getSubOrderStatus())) throw conflict("关联尾程状态已变化,不能覆盖"); + tail.setSubOrderStatus(previous.tailStatus()); tail.setAbnormal(previous.abnormal()); + tail.setUpdatedAt(LocalDateTime.now()); lastMileOrders.updateById(tail); + } + } + private List allowedActions(ExceptionOrder order) { return switch (Objects.requireNonNullElse(order.getStatus(), "")) { case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java new file mode 100644 index 000000000..e8336d790 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +final class ReturnReceiptPolicy { + private ReturnReceiptPolicy() { } + + static Map quantities(List sources, List receipts) { + Map amounts = new LinkedHashMap<>(); + if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细"); + for (ReturnReceipt receipt : receipts) { + ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId()) + && Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst() + .orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源")); + if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零"); + amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact); + } + for (ExceptionReturnItem source : sources) { + long total = Math.addExact(received(source), amounts.getOrDefault(source.getId(), 0L)); + if (total > source.getApprovedReturnPackages()) throw conflict("累计实收箱数超过批准退回数量"); + } + return amounts; + } + + static long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); } + static boolean complete(List items) { + return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages()); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java index 7b53de839..82770b304 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java @@ -4,11 +4,17 @@ 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 org.springframework.web.bind.annotation.PathVariable; import java.util.List; @FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}") public interface FleetWarehouseClient { @GetMapping("/internal/warehouses/order-catalog") ApiResponse> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token); + @GetMapping("/internal/warehouses/0/{warehouseId}/active") + ApiResponse activeDetail(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("warehouseId") long warehouseId); record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { } + record Identity(Long id, Long tenantId, String warehouseType, String status, String timezone) { } + record Address(Long warehouseId, Long tenantId, String city) { } + record Detail(Identity warehouse, List
addresses) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java index 2c6cdd9af..2e0a9966f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -28,7 +28,7 @@ public class FleetHandoverPdaQuery { FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) { this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json; } - public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { } + public record Delivery(String deliveryNo, String status, int packages, int remainingPackages, Instant terminalAt, FleetTransitEvidence.Transport transport) { } public record Handover(String handoverNo, Instant handedOverAt, String location, FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { } public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion, @@ -53,6 +53,7 @@ public class FleetHandoverPdaQuery { if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound(); var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone()); var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), + row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(), row.cargo().terminalAt(), row.transport())).toList(); var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); Handover handover = null; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java index 8be21f895..bef114894 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java @@ -12,7 +12,8 @@ public final class FleetHandoverPolicy { String destinationKey, int packages) { } /** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */ public record Delivery(String customerId, String dispatchNo, String deliveryNo, - String destinationKey, int packages, Instant terminalAt) { } + String destinationKey, int packages, Instant terminalAt, List returnedCargo) { } + public record ReturnedCargo(String itemId, String palletId, int packages, Instant receivedAt) { } public record Pallet(String palletId, Load measuredLoad) { } public record Inspection(List remainingCargo, List completedDeliveries, Set remainingPalletIds, Load occupied) { } @@ -48,6 +49,7 @@ public final class FleetHandoverPolicy { throw invalid("同一实托包含不同卸货目的地,请先核对原始装载"); } Map byDestination = new HashMap<>(); + Map returned = new HashMap<>(); Set deliveryKeys = new HashSet<>(); List completed = new ArrayList<>(); for (Delivery delivery : deliveries) { @@ -60,6 +62,16 @@ public final class FleetHandoverPolicy { throw invalid("派送事实重复或目的地拆分尚未核平"); if (!Objects.equals(shipped.get(key), delivery.packages())) throw invalid("派送客户、排车、目的地或箱数与原始发运不一致"); + if (delivery.returnedCargo() == null) throw invalid("缺少实际退回数量依据"); + for (ReturnedCargo receipt : delivery.returnedCargo()) { + Item item = new Item(delivery.customerId(), delivery.dispatchNo(), receipt.itemId()); + Cargo source = departureCargo.stream().filter(cargo -> item.equals(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()))) + .findFirst().orElseThrow(() -> invalid("退回来源不属于原始发运明细")); + if (!destination(source).equals(key) || !Objects.equals(source.palletId(), receipt.palletId()) || receipt.packages() <= 0 + || receipt.receivedAt() == null || receipt.receivedAt().isBefore(departedAt) || receipt.receivedAt().isAfter(handedOverAt)) + throw invalid("退回来源、数量或实际时间与交接不一致"); + if (returned.merge(item, receipt.packages(), Math::addExact) > source.packages()) throw invalid("累计退回超过原发运明细数量"); + } if (delivery.terminalAt() != null) { if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt)) throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对"); @@ -76,7 +88,10 @@ public final class FleetHandoverPolicy { } if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致"); List remaining = departureCargo.stream() - .filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList(); + .filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null) + .map(cargo -> new Cargo(cargo.customerId(), cargo.dispatchNo(), cargo.itemId(), cargo.palletId(), cargo.destinationKey(), + cargo.packages() - returned.getOrDefault(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()), 0))) + .filter(cargo -> cargo.packages() > 0).toList(); if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理"); Set palletIds = new TreeSet<>(); remaining.forEach(cargo -> palletIds.add(cargo.palletId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java new file mode 100644 index 000000000..4f3efa6c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.quote; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; + +public final class FleetQuoteCandidateModels { + private FleetQuoteCandidateModels() { } + /** The booking owner resolves this from its actual customer, route, currency and agreed service date. */ + public record Context(@NotNull @Positive Long customerId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn, + @NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis) { } + public record Query(@NotNull @Positive Long customerId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn, + @NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis, + @Min(1) Integer page,@Min(1) @Max(100) Integer size) { + public Query { page=page==null?1:page;size=size==null?20:size; } + public Context context(){return new Context(customerId,originCity,destinationCode,currency,effectiveOn,priceBasis);} + } + public record QuoteRef(@NotBlank @Size(max=64) String quoteNo,@NotNull @Min(0) Long recordVersion, + @NotBlank @Pattern(regexp="FULL_DELIVERY|LOOSE_DELIVERY") String priceItem) { } + /** Quoted price is a source fact, distinct from the booking owner's negotiated price/quantity/amount. */ + public record SourceSnapshot(String quoteNo,long recordVersion,String priceItem,String customerId,String customerName, + String originCity,String destinationCode,BigDecimal distanceMiles,String currency,String priceBasis, + BigDecimal quotedUnitPrice,LocalDate validFrom,LocalDate validTo,LocalDate effectiveOn) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java new file mode 100644 index 000000000..48feedf7a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.quote; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** Read-only projection of the existing quote library; no second quote persistence model or writes. */ +@Repository +public class FleetQuoteCandidateRepository { + record QuoteRow(String quoteNo,String partyName,String status,long version,String details) { } + private static final RowMapper ROW=(r,i)->new QuoteRow(r.getString("quote_no"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("details_json")); + private final JdbcTemplate jdbc; + public FleetQuoteCandidateRepository(JdbcTemplate jdbc){this.jdbc=jdbc;} + List candidates(FleetQuoteCandidateModels.Context context) { + return jdbc.query(""" + SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote + WHERE kind='SALE' AND status='ACTIVE' AND party_id=? AND LOWER(TRIM(origin_city))=? + AND destination_code=? AND valid_from<=? AND valid_to>=? + ORDER BY valid_from DESC,quote_no DESC LIMIT 5001 + """,ROW,context.customerId(),context.originCity(),context.destinationCode(),context.effectiveOn(),context.effectiveOn()); + } + QuoteRow lock(String quoteNo) { + return jdbc.query("SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote WHERE quote_no=? FOR UPDATE",ROW,quoteNo).stream().findFirst().orElse(null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java new file mode 100644 index 000000000..b0a31a79b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.*; + +@Service +public class FleetQuoteCandidateService { + private final FleetQuoteCandidateRepository repository; + private final FleetQuoteDirectory directory; + private final ObjectMapper json; + public FleetQuoteCandidateService(FleetQuoteCandidateRepository repository,FleetQuoteDirectory directory,ObjectMapper json){this.repository=repository;this.directory=directory;this.json=json;} + + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult candidates(FleetQuoteCandidateModels.Query query) { + var context=normalize(query.context());directory.name("SALE",context.customerId()); + var rows=repository.candidates(context); + if(rows.size()>5000)throw error(HttpStatus.BAD_REQUEST,"当前路线有效报价过多,请先整理报价库"); + var candidates=new ArrayList(); + for(var row:rows) { + var draft=read(row.details()); + if(matches(row,draft,context) && price(draft,context.priceBasis())!=null)candidates.add(snapshot(row,draft,context)); + } + long total=candidates.size();int start=(int)Math.min(total,((long)query.page()-1)*query.size()); + return new PageResult<>(List.copyOf(candidates.subList(start,Math.min(candidates.size(),start+query.size()))),query.page(),query.size(),total,(total+query.size()-1)/query.size()); + } + + /** Called only inside the booking owner's write transaction, after its permission and scope checks. + * The quote row lock lasts until that transaction commits; caller persists this returned snapshot. + * No public write endpoint, no booking writes and no negotiated-price calculation live here. + */ + @Transactional(propagation=Propagation.MANDATORY) + public FleetQuoteCandidateModels.SourceSnapshot lockAndResolve(FleetQuoteCandidateModels.Context requested,FleetQuoteCandidateModels.QuoteRef reference) { + var context=normalize(requested); + if(reference==null || reference.quoteNo()==null || reference.quoteNo().isBlank() || reference.recordVersion()==null || reference.recordVersion()<0 + || !item(context.priceBasis()).equals(reference.priceItem()))throw error(HttpStatus.BAD_REQUEST,"报价引用或计价项目无效"); + var row=repository.lock(reference.quoteNo()); + if(row==null)throw error(HttpStatus.NOT_FOUND,"报价不存在"); + var draft=read(row.details()); + if(row.version()!=reference.recordVersion() || !matches(row,draft,context))throw error(HttpStatus.CONFLICT,"报价版本、客户、路线、币种或有效期已不匹配,请重新选择"); + if(price(draft,context.priceBasis())==null)throw error(HttpStatus.CONFLICT,"所选项目尚未报价,不能作为零价使用"); + directory.name("SALE",context.customerId()); + return snapshot(row,draft,context); + } + private static FleetQuoteCandidateModels.Context normalize(FleetQuoteCandidateModels.Context context) { + if(context==null || context.customerId()==null || context.customerId()<=0 || context.originCity()==null || context.originCity().isBlank() + || context.destinationCode()==null || context.destinationCode().isBlank() || context.effectiveOn()==null + || !Set.of("USD","CNY").contains(Objects.toString(context.currency(),"")) || !Set.of("ORDER","PALLET").contains(Objects.toString(context.priceBasis(),""))) + throw error(HttpStatus.BAD_REQUEST,"请选择明确客户、路线、币种、服务日期及计价单位"); + return new FleetQuoteCandidateModels.Context(context.customerId(),context.originCity().trim().toLowerCase(Locale.ROOT),context.destinationCode().trim().toUpperCase(Locale.ROOT),context.currency(),context.effectiveOn(),context.priceBasis()); + } + private static boolean matches(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) { + return "ACTIVE".equals(row.status()) && "SALE".equals(d.kind()) && c.customerId().equals(d.partyId()) + && c.originCity().equals(d.originCity().trim().toLowerCase(Locale.ROOT)) && c.destinationCode().equals(d.destinationCode().trim().toUpperCase(Locale.ROOT)) + && c.currency().equals(d.currency()) && !c.effectiveOn().isBefore(d.validFrom()) && !c.effectiveOn().isAfter(d.validTo()); + } + private static String item(String basis){return "PALLET".equals(basis)?"LOOSE_DELIVERY":"FULL_DELIVERY";} + private static BigDecimal price(FleetQuoteModels.Draft d,String basis){return "PALLET".equals(basis)?d.looseDeliveryPrice():d.fullDeliveryPrice();} + private static FleetQuoteCandidateModels.SourceSnapshot snapshot(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) { + return new FleetQuoteCandidateModels.SourceSnapshot(row.quoteNo(),row.version(),item(c.priceBasis()),d.partyId().toString(),row.partyName(),d.originCity(),d.destinationCode(),d.distanceMiles(),d.currency(),c.priceBasis(),price(d,c.priceBasis()),d.validFrom(),d.validTo(),c.effectiveOn()); + } + private FleetQuoteModels.Draft read(String value){try{return json.readValue(value,FleetQuoteModels.Draft.class);}catch(JsonProcessingException e){throw new IllegalStateException("报价来源快照无法读取",e);}} + private static ResponseStatusException error(HttpStatus status,String message){return new ResponseStatusException(status,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java new file mode 100644 index 000000000..dffeffd21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.*; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/quotes/sale-candidates") +public class PlatformFleetQuoteCandidateController { + private final FleetQuoteCandidateService service; + public PlatformFleetQuoteCandidateController(FleetQuoteCandidateService service){this.service=service;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> candidates(@Valid FleetQuoteCandidateModels.Query query){return ApiResponse.success(service.candidates(query));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java index e01951ed1..fc212727d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java @@ -16,6 +16,7 @@ public class FleetBookingEntity { private String customerName; private String status; private String cargoJson; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson; private String destinationKey; private String destinationLabel; @TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java index d6e150d30..1016e380a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java @@ -1,5 +1,6 @@ package com.baiyue.lastleg.fleet.trip; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.math.BigDecimal; @@ -14,8 +15,13 @@ public final class FleetBookingModels { @Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice, @Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { } public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId, - @NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey) { } - public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo) { } + @NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { } + public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { } + public record QuoteCandidatesQuery(@NotNull @Positive Long customerId,@NotBlank @Size(max=160) String destinationKey, + @NotBlank @Pattern(regexp="CNY|USD") String currency,@NotBlank @Pattern(regexp="PALLET|ORDER") String priceBasis, + @Min(1) Integer page,@Min(1) @Max(100) Integer size) { + public QuoteCandidatesQuery {page=page==null?1:page;size=size==null?20:size;} + } public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { } public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion, @@ -32,7 +38,8 @@ public final class FleetBookingModels { public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { } public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List pallets,String operatorId,Instant confirmedAt) { } public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName, - String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt) { } + String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt, + FleetQuoteCandidateModels.SourceSnapshot quoteSource) { } public record Result(long tripRecordVersion, Response booking, boolean replayed) { } public record ListResult(long tripRecordVersion, List records) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java new file mode 100644 index 000000000..0682f0f7a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; + +/** Resolve quote eligibility from the trip and authoritative warehouse facts, never browser hints. */ +@Component +public class FleetBookingQuoteContext { + private final FleetWarehouseClient warehouses; + private final FleetBookingReservations reservations; + private final String token; + public FleetBookingQuoteContext(FleetWarehouseClient warehouses, FleetBookingReservations reservations, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.warehouses = warehouses; this.reservations = reservations; this.token = token; + } + + public FleetQuoteCandidateModels.Context resolve(FleetTripEntity trip, long customerId, + String destinationKey, String currency, String priceBasis) { + if (customerId <= 0 || trip.getWarehouseId() == null || trip.getPlannedDepartureAt() == null + || FleetBookingReservations.index(reservations.stops(trip), destinationKey) < 1) + throw conflict("请选择本车已有卸货站和委托客户"); + if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4) + throw conflict("该卸货站没有报价库目的仓码,请手填本次成交价"); + if (!Set.of("CNY", "USD").contains(currency == null ? "" : currency) + || !Set.of("PALLET", "ORDER").contains(priceBasis == null ? "" : priceBasis)) + throw conflict("请选择报价币种及计价方式"); + FleetWarehouseClient.Detail detail; + try { + var response = warehouses.activeDetail(token, trip.getWarehouseId()); + if (response == null || !response.successful() || response.data() == null) throw unavailable(); + detail = response.data(); + } catch (ResponseStatusException e) { throw e; } + catch (RuntimeException e) { throw unavailable(); } + var warehouse = detail.warehouse(); + if (warehouse == null || !trip.getWarehouseId().equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId()) + || !"ACTIVE".equals(warehouse.status()) || !Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType() == null ? "" : warehouse.warehouseType())) + throw conflict("起运仓资料已变化,请重新核对"); + var cities = new TreeSet(); + if (detail.addresses() != null) for (var address : detail.addresses()) { + if (address != null && trip.getWarehouseId().equals(address.warehouseId()) && Long.valueOf(0).equals(address.tenantId()) + && address.city() != null && !address.city().isBlank()) cities.add(address.city().trim().toLowerCase(Locale.ROOT)); + } + if (cities.size() != 1) throw conflict("起运仓城市尚未明确,请完善仓库资料或手填本次成交价"); + ZoneId zone; + try { zone = ZoneId.of(warehouse.timezone() == null ? "" : warehouse.timezone().trim()); } + catch (DateTimeException e) { throw conflict("起运仓时区尚未明确,请完善仓库资料或手填本次成交价"); } + var date = trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).atZone(zone).toLocalDate(); + return new FleetQuoteCandidateModels.Context(customerId, cities.first(), destinationKey.substring(4), currency, date, priceBasis); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "起运仓资料暂不可用,请重试"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index d05314d41..8f534866d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.fleet.trip; import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -21,8 +22,9 @@ public class FleetBookingReservations { public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); } public FleetBookingModels.Response response(FleetBookingEntity row) { return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(), - row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row)); + row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row)); } + public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);} public List segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, Long excludedId, FleetBookingEntity addition) { var stops=stops(trip); var loads=new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index b53a8bcce..5efcb2111 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -2,6 +2,10 @@ package com.baiyue.lastleg.fleet.trip; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService; +import org.springframework.security.core.context.SecurityContextHolder; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import org.springframework.http.HttpStatus; @@ -27,12 +31,21 @@ public class FleetBookingService { private final FleetCustomerDirectory customers; private final ObjectMapper json; private final FleetBookingIncomingGateway incoming; + private final FleetBookingQuoteContext quoteContext; + private final FleetQuoteCandidateService quotes; public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, - FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming){ + FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, + FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming; + this.quoteContext=quoteContext;this.quotes=quotes; } @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));} + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public PageResult quoteCandidates(String tripNo,FleetBookingModels.QuoteCandidatesQuery query){ + var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis()); + return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size())); + } @Transactional public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){ var trip=trip(tripNo,true);String hash=hash(request); @@ -41,7 +54,7 @@ public class FleetBookingService { requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId()); var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo); row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT"); - apply(trip,row,request.cargo());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L); + apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L); row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt()); bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator); return result(trip,row,false); @@ -50,7 +63,7 @@ public class FleetBookingService { public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){ var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion()); if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留"); - apply(trip,row,request.cargo());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false); + apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false); } @Transactional public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){ @@ -61,6 +74,8 @@ public class FleetBookingService { customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row); if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种"); if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种"); + var source=reservations.quoteSource(row); + if(source!=null)row.setQuoteSourceJson(write(resolveQuote(trip,row,new FleetQuoteCandidateModels.QuoteRef(source.quoteNo(),source.recordVersion(),source.priceItem())))); var plan=execution.capacityPlan(tripNo); if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运"); reservations.segments(trip,plan.capacityPlan(),null,row); @@ -141,6 +156,19 @@ public class FleetBookingService { if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); } + private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){ + var cargo=reservations.cargo(row); + return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference); + } + private void applyQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){ + if(reference!=null || row.getQuoteSourceJson()!=null)requireQuotePermission(); + row.setQuoteSourceJson(reference==null?null:write(resolveQuote(trip,row,reference))); + } + private static void requireQuotePermission(){ + var authentication=SecurityContextHolder.getContext().getAuthentication(); + if(authentication==null || authentication.getAuthorities().stream().noneMatch(a->"platform:fleet:quote:list".equals(a.getAuthority()))) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"引用或清除报价来源需要报价读取权限"); + } private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){ var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey()); if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java index 7893b8bdf..e3b6ef2f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java @@ -7,7 +7,7 @@ import java.util.List; public final class FleetCompletionModels { private FleetCompletionModels() { } public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination, - String status, int packages, Instant terminalAt) { } + String status, int packages, Instant terminalAt, int signedPackages, int returnedPackages, int remainingPackages) { } public record DispatchCompletion(String dispatchNo, String warehouseId, List outcomes, String blockReason) { } public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List outcomes) { } public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java index d545d3b1b..580f9122a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -117,7 +117,9 @@ public class FleetTripCompletionService { if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; } } Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now(); - if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.terminalAt() == null + if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.packages() <= 0 || outcome.remainingPackages() != 0 + || outcome.signedPackages() < 0 || outcome.returnedPackages() < 0 + || (long) outcome.signedPackages() + outcome.returnedPackages() != outcome.packages() || outcome.terminalAt() == null || outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单"; } return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java index 239d31065..0bb897829 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.fleet.trip; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; import jakarta.validation.Valid; @@ -20,6 +22,8 @@ public class PlatformFleetBookingController { public ApiResponse> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));} @GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));} + @GetMapping("/{tripNo}/bookings/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:trip:list') and hasAuthority('platform:fleet:quote:list')") + public ApiResponse> quoteCandidates(@PathVariable String tripNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(tripNo,query));} @PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));} @PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')") diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f9fcca611..441e97d42 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -311,6 +311,9 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + signed_packages INT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量', + CONSTRAINT chk_delivery_signed_packages CHECK(signed_packages IS NULL OR (signed_packages>=0 AND signed_packages<=package_count)), + PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), @@ -1138,6 +1141,7 @@ CREATE TABLE ll_fleet_external_booking ( customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键', destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称', agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收', @@ -2636,8 +2640,10 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + return_context_json JSON NULL COMMENT '退回异常创建前的派送及尾程状态快照', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), - UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code) ) ENGINE=InnoDB COMMENT='海外仓异常订单'; @@ -2648,6 +2654,9 @@ CREATE TABLE IF NOT EXISTS ll_exception_return_item ( inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', + CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), KEY idx_ll_exception_return_order(customer_id,exception_order_id) ) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index fa84f738a..bf3b1b5fc 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -6,6 +6,7 @@ info: 平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 RECEIPT 签收文件只有在关联出库单已OUTBOUNDED、PUT保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。发车前可保存BOL及装车资料,不能通过提前保存签收文件绕过实际出库。allowedActions 不再返回人工 COMPLETE_DELIVERY。 + 首次签收冻结signedPackages为原实发箱数减去此前实际退回入账箱数;退回异常仍在处理时不得签收余货。后续资料修改或退货不改写原signedAt和signedPackages。部分退回后的余货签收保留国内回写待处理操作记录,不发送会把整票入仓单标记已签收的OVERSEAS_DELIVERY_COMPLETED事件;该跨服务分支尚未闭环。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] diff --git a/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml index c2bf97b16..a232bc1e1 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml @@ -37,6 +37,7 @@ paths: deliveryNo: { type: string } status: { type: string } packages: { type: integer } + remainingPackages: { type: integer, minimum: 0, description: 原运输尚未签收且尚未实际退回的箱数 } terminalAt: { type: string, format: date-time, nullable: true } transport: { $ref: '#/components/schemas/Transport' } latestHandover: @@ -222,6 +223,16 @@ components: destinationKey: { type: string } packages: { type: integer } terminalAt: { type: string, format: date-time, nullable: true } + returnedCargo: + type: array + description: 由实际退回入账流水映射到原发运明细;部分退回按此扣减箱数,未清空实托保留原测量载荷。 + items: + type: object + properties: + itemId: { type: string } + palletId: { type: string } + packages: { type: integer, minimum: 1 } + receivedAt: { type: string, format: date-time } Inspection: type: object properties: diff --git a/lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml new file mode 100644 index 000000000..7b866c833 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml @@ -0,0 +1,82 @@ +openapi: 3.0.3 +info: + title: 外接销售报价候选与来源快照 + version: 1.0.0 + description: >- + 候选只读取有效销售报价,不创建承运、库存或费用。客户、起运城市、目的仓码、币种、计价单位和日期明确匹配; + 空价排除,零价保留,重叠有效期不自动选价。PALLET展示LOOSE_DELIVERY,ORDER展示FULL_DELIVERY价项; + 返回整车派送价项不表示已作为拼载成交价采用,必须明确选择报价号、版本及价项。 + 日期由调用方根据真实车次计划起运时间和明确业务时区产生,不猜测机器或浏览器时区。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/quotes/sale-candidates: + get: + description: platform:fleet:quote:list;同路线原始候选超过5000条明确拒绝,不能截断后声称无报价。 + parameters: + - { name: customerId, in: query, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { name: originCity, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + - { name: destinationCode, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 64 } } + - { name: currency, in: query, required: true, schema: { type: string, enum: [CNY, USD] } } + - { name: effectiveOn, in: query, required: true, schema: { type: string, format: date } } + - { name: priceBasis, in: query, required: true, schema: { type: string, enum: [ORDER, PALLET] } } + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + responses: + '200': + description: 成功业务码为200;失败保留业务码及requestId/traceId,不以空页冒充目录可用。 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + message: { type: string } + requestId: { type: string } + traceId: { type: string } + data: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/SourceSnapshot' } } + page: { type: integer, minimum: 1 } + size: { type: integer, minimum: 1 } + total: { type: integer, minimum: 0 } + pages: { type: integer, minimum: 0 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + QuoteRef: + type: object + required: [quoteNo, recordVersion, priceItem] + properties: + quoteNo: { type: string, minLength: 1, maxLength: 64 } + recordVersion: { type: integer, format: int64, minimum: 0 } + priceItem: { type: string, enum: [FULL_DELIVERY, LOOSE_DELIVERY] } + SourceSnapshot: + type: object + required: [quoteNo, recordVersion, priceItem, customerId, customerName, originCity, destinationCode, distanceMiles, currency, priceBasis, quotedUnitPrice, validFrom, validTo, effectiveOn] + properties: + quoteNo: { type: string } + recordVersion: { type: integer, format: int64, minimum: 0 } + priceItem: { type: string, enum: [FULL_DELIVERY, LOOSE_DELIVERY] } + customerId: { type: string } + customerName: { type: string, description: 报价版本中的客户名称快照 } + originCity: { type: string } + destinationCode: { type: string } + distanceMiles: { type: number, minimum: 0 } + currency: { type: string, enum: [CNY, USD] } + priceBasis: { type: string, enum: [ORDER, PALLET] } + quotedUnitPrice: { type: number, minimum: 0, description: 正式报价单价;不是承运方最终成交单价或实际应收 } + validFrom: { type: string, format: date } + validTo: { type: string, format: date, description: 包含当天 } + effectiveOn: { type: string, format: date } +x-booking-integration: + method: FleetQuoteCandidateService.lockAndResolve(Context requested, QuoteRef reference) + transaction: MANDATORY;由承运写事务调用。SELECT FOR UPDATE锁住原报价直到外层提交,禁止事务外解析后再单独保存。 + context: customerId/originCity/destinationCode/currency/effectiveOn/priceBasis;必须由承运服务按客户、真实路线、计划起运时间和明确业务时区核验,不能原样信任浏览器条件。 + validation: 重新核对SALE、ACTIVE、精确版本、客户、路线、币种、日期及明确选择的价项。未知价拒绝,零价允许;采购、自提、停用或陈旧报价不能引用。 + snapshot: 保存返回的SourceSnapshot,禁止使用浏览器提交的来源快照。来源单价与承运实际成交单价、计价量、金额分别保存,后续报价改价不覆盖历史。 + manual: 未引用时不调用解析方法,保留手填成交价能力;引用报价不自动生成应收、成本或付款。 + ownership: 提供端不修改FleetBooking、共享UI或SQL;接入方负责写权限、幂等重放优先返回原结果和实际成交快照持久化。 diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index bb1460d92..860c605cd 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -65,6 +65,37 @@ paths: description: '权限:platform:fleet:trip:list;读取IAM当前客户主档;不使用采购历史或允许输入不存在的客户。' parameters: [{name: keyword, in: query, schema: {type: string}}] responses: {'200': {description: 统一响应data为客户id/code/name/contactName/contactPhone数组}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/quote-candidates: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 按本车真实起运仓及当地计划日期查询客户销售报价 + description: '同时要求platform:fleet:trip:list与platform:fleet:quote:list。城市来自有效公共海外仓的唯一明确地址城市,日期为计划起运UTC时间转换到仓库明确时区;不接收浏览器城市或日期。目的地须为本车既有FBA卸货站;私人地址、缺失或歧义城市/时区明确拒绝查询,仍可手工询价。' + parameters: + - {name: customerId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: destinationKey, in: query, required: true, schema: {type: string, maxLength: 160}} + - {name: currency, in: query, required: true, schema: {type: string, enum: [CNY, USD]}} + - {name: priceBasis, in: query, required: true, schema: {type: string, enum: [PALLET, ORDER]}} + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': + description: 统一业务响应,data为报价来源候选分页;无价不列入,零价保留 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: 'fleet-quote-candidates-v1.yaml#/components/schemas/SourceSnapshot'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + '403': {description: 缺少任一读取权限} + '409': {description: 无法确定本车报价查询上下文} /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings: parameters: [{$ref: '#/components/parameters/TripNo'}] get: @@ -379,6 +410,7 @@ components: properties: tripRecordVersion: {type: integer, format: int64, minimum: 0} customerId: {type: string, pattern: '^[1-9][0-9]*$'} + quoteRef: {$ref: '#/components/schemas/BookingQuoteRef'} cargo: {$ref: '#/components/schemas/BookingCargo'} idempotencyKey: {type: string, minLength: 8, maxLength: 128} BookingUpdate: @@ -387,7 +419,13 @@ components: properties: tripRecordVersion: {type: integer, format: int64, minimum: 0} recordVersion: {type: integer, format: int64, minimum: 0} + quoteRef: {$ref: '#/components/schemas/BookingQuoteRef'} cargo: {$ref: '#/components/schemas/BookingCargo'} + BookingQuoteRef: + type: object + nullable: true + description: '选填;null或省略为手工询价。引用或清除已有来源另要求platform:fleet:quote:list。服务端在承运写事务内锁定并核对精确版本、SALE/ACTIVE、客户、路线、币种、仓库当地计划日期及价项;只保存可信来源,不接收浏览器快照。PALLET选LOOSE_DELIVERY,ORDER选FULL_DELIVERY。' + allOf: [{$ref: 'fleet-quote-candidates-v1.yaml#/components/schemas/QuoteRef'}] BookingCommand: type: object required: [tripRecordVersion, recordVersion, idempotencyKey] @@ -406,6 +444,11 @@ components: customerName: {type: string} status: {type: string, enum: [DRAFT, CONFIRMED, RECEIVED, BOUND, CANCELLED]} cargo: {$ref: '#/components/schemas/BookingCargo'} + quoteSource: + type: object + nullable: true + description: '可信报价来源,独立于cargo.unitPrice及agreedAmount。确认承运重新核对报价及当前计划日期;确认后的报价变更或实收议价不覆盖已保存来源。' + allOf: [{$ref: 'fleet-quote-candidates-v1.yaml#/components/schemas/SourceSnapshot'}] destinationLabel: {type: string} agreedAmount: {type: number, nullable: true, description: 约定运费,非财务应收或实收} incomingNo: {type: string, nullable: true} @@ -421,8 +464,8 @@ components: recordVersion: {type: integer, format: int64} updatedAt: {type: string, format: date-time} Outcome: + description: packages保留原实发量;signedPackages为不可变实签量,returnedPackages只计原运输实际退回,remainingPackages必须为零且三者守恒才可结案。签收后的另行退货不重开原运输。 type: object - description: 本车原运输的结案依据;已签收后另发起退货仍保留本次运输DELIVERED及原signedAt,不修改后续退货单据状态。 properties: dispatchNo: {type: string} deliveryNo: {type: string} @@ -431,6 +474,9 @@ components: status: {type: string} packages: {type: integer} terminalAt: {type: string, format: date-time, nullable: true} + signedPackages: {type: integer, minimum: 0} + returnedPackages: {type: integer, minimum: 0} + remainingPackages: {type: integer, minimum: 0} CompletionEnvelope: allOf: - {$ref: '#/components/schemas/Envelope'} 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 c91b76114..7a0c3751c 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -898,7 +898,7 @@ paths: /api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete: post: summary: 完成异常处理并原子恢复退回库存 - description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。 + description: 权限 platform:overseas-warehouse:exception-order:process;退回海外仓支持分批实收,每批使用新幂等键及当前版本,累计不得超过批准量。库存 RETURN_STOCK_IN 成功后累计实收,未收齐保留 PROCESSING,批准量收齐才转 COMPLETED。详情 returnSources 只返回剩余可收数量。派送仅在累计实际退回覆盖整单实发数量时写 RETURNED 和 RETURN_COMPLETED;非海外仓异常结案不证明货物交接完成。原 signedAt 保留,车次及资源不由工单关闭释放。 security: [{ bearerAuth: [] }] parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } } @@ -906,7 +906,7 @@ paths: /api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel: post: summary: 取消未处理异常订单 - description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED,已有实收禁止取消。退回单按创建时冻结的派送及关联尾程状态原子恢复,保留原签收事实;缺少冻结状态或当前状态已变化时拒绝猜测恢复。 security: [{ bearerAuth: [] }] parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } } @@ -927,7 +927,7 @@ paths: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return: post: summary: 将派送单标记为退回入仓 - description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。 + description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端扣除历史实际退回量后冻结源出库明细与批准退回上限。同一派送同时只允许一个 PENDING/PROCESSING 退回异常,取消或完成后可为剩余货物新建;RETURN_PENDING 不允许绕过尚未完成的退回异常。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java new file mode 100644 index 000000000..607d842cf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.delivery.model; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class DeliveryCargoQuantitiesTest { + private final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 12, 0); + @Test void unsignedQuantityRemainsOpenUntilAllCargoHasActualTerminalFacts() { + var row = new DeliveryOrder(); row.setPackageCount(10); + assertThat(DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now)))) + .isEqualTo(new DeliveryCargoQuantities(10, 0, 4, 6)); + row.setSignedAt(now.plusMinutes(1)); row.setSignedPackages(6); + assertThat(DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now), receipt(2, 2, now.plusMinutes(2))))) + .isEqualTo(new DeliveryCargoQuantities(10, 6, 4, 0)); + } + @Test void missingSignatureQuantityCannotDefaultToTheWholeShipment() { + var row = new DeliveryOrder(); row.setPackageCount(10); row.setSignedAt(now); + assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of())).hasMessageContaining("签收数量"); + } + @Test void duplicatedSourcesOrOverReturnCannotCloseCargo() { + var row = new DeliveryOrder(); row.setPackageCount(10); + assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now), receipt(1, 4, now)))) + .hasMessageContaining("重复"); + assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 11, now)))).hasMessageContaining("超过"); + } + @Test void laterReturnCannotFillAnUnprovenGapInAnEarlierSignature() { + var row = new DeliveryOrder(); row.setPackageCount(10); row.setSignedAt(now); row.setSignedPackages(6); + assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now.plusMinutes(1))))) + .hasMessageContaining("守恒"); + } + private DeliveryReturnReceiptFact receipt(long id, long packages, LocalDateTime time) { return new DeliveryReturnReceiptFact(id, 2L, 3L, packages, time); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java index 94c949b2b..341485da5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -13,7 +13,7 @@ class DeliveryStatusPolicyTest { .containsExactly("REJECT_DELIVERY", "MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); assertThat(DeliveryStatusPolicy.isEditableTarget("DELIVERED")).isFalse(); - assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty(); + assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).containsExactly("MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 679162bc0..8b5778182 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -31,6 +31,7 @@ class DeliveryCompletedOutboxServiceTest { ReflectionTestUtils.setField(service, "objectMapper", json); DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(1L); delivery.setDeliveryNo("DL-1"); delivery.setVersion(2L); + delivery.setPackageCount(10); delivery.setSignedPackages(10); service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z")); @@ -100,6 +101,19 @@ class DeliveryCompletedOutboxServiceTest { verifyNoInteractions(mapper); } + @Test void unknownOrPartialSignatureCannotPublishAWholeDomesticInboundSignature() { + var mapper = mock(LastlegEventOutboxMapper.class); + var service = new DeliveryCompletedOutboxService(); ReflectionTestUtils.setField(service, "outbox", mapper); + var delivery = new DeliveryOrder(); delivery.setPackageCount(10); + var sources = List.of(order(11, "101", "201", "IN-1", "ONT8")); + for (Integer invalid : java.util.Arrays.asList(null, 0, -1, 11)) { + delivery.setSignedPackages(invalid); + assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION); + } + delivery.setSignedPackages(6); + assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); + verifyNoInteractions(mapper); + } private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index fc320bcee..d0057df50 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -48,7 +48,7 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"customerVisits",customerVisits); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); - order.setOutboundNo("OUT-1"); + order.setOutboundNo("OUT-1"); order.setPackageCount(10); } @Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() { when(orders.lockByNo("100","DL-1")).thenReturn(order); @@ -215,6 +215,30 @@ class DeliveryDocumentServiceTest { verify(files,never()).download(anyString(),anyLong(),any(),anyLong()); verifyNoInteractions(revisions,operations); } + @Test void remainingSignatureFreezesActualQuantityAndPersistsDomesticWritebackBlocker() { + order.setStatus("RETURN_PENDING"); + when(orders.lockByNo("100", "DL-1")).thenReturn(order); + when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13)); + var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED"); + when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); + when(operations.returnReceipts("100", 10L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 2L, 3L, 4, LocalDateTime.now().minusMinutes(1)))); + when(deliveryCompletedOutbox.publish(eq(order), anyList(), any())).thenReturn(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); + service.save("100", "DL-1", "9", request(0, "residual-signature", List.of(new Binding(RECEIPT, List.of(13L))))); + assertThat(order.getPackageCount()).isEqualTo(10); assertThat(order.getSignedPackages()).isEqualTo(6); + assertThat(order.getSignedAt()).isNotNull(); + var captured = ArgumentCaptor.forClass(DeliveryOperation.class); + verify(operations, times(3)).insert(captured.capture()); + assertThat(captured.getAllValues()).anyMatch(row -> "FIRSTLEG_COMPLETION_PENDING".equals(row.getOperationNode()) && row.getResult().contains("未发布")); + } + @Test void updatingDocumentsDuringLaterReturnNeverRewritesOriginalSignature() { + order.setStatus("RETURN_PENDING"); order.setSignedAt(LocalDateTime.of(2026, 9, 1, 12, 0)); order.setSignedPackages(10); + when(orders.lockByNo("100", "DL-1")).thenReturn(order); + when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13)); + service.save("100", "DL-1", "9", request(0, "later-evidence", List.of(new Binding(RECEIPT, List.of(13L))))); + assertThat(order.getSignedAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 12, 0)); + assertThat(order.getSignedPackages()).isEqualTo(10); assertThat(order.getStatus()).isEqualTo("RETURN_PENDING"); + verifyNoInteractions(deliveryCompletedOutbox, customerVisits); + } private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java new file mode 100644 index 000000000..7104318a5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DeliveryReturnEvidencePersistenceTest { + @Test void twoItemsOnTheSamePalletResolveThroughTheOriginalPickPositionAndOrder() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(data); + jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_order_id BIGINT,outbound_no VARCHAR(20))"); + jdbc.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(20),last_mile_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_exception_return_item(exception_order_id BIGINT,customer_id VARCHAR(20),outbound_pallet_item_id BIGINT,inventory_position_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_outbound_pallet_item(id BIGINT,customer_id VARCHAR(20),outbound_order_id BIGINT,warehouse_pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,warehouse_pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT,inventory_position_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)"); + jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')"); + jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)"); + jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)"); + jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(21,'A',20,99)"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(31,'A',10,99),(32,'A',10,99)"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(50,'A',10)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(51,'A',50,31,41),(52,'A',50,32,42)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',51,71),('A',52,72)"); + jdbc.update("INSERT INTO ll_inventory_ledger VALUES(60,'A','EX-1','OUT-1','RETURN_STOCK_IN',4,'RETURN_SOURCE:21:41',CURRENT_TIMESTAMP)"); + String sql = String.join(" ", DeliveryOperationMapper.class.getMethod("returnReceipts", String.class, Long.class) + .getAnnotation(Select.class).value()).replace("#{customerId}", ":customerId").replace("#{deliveryOrderId}", ":deliveryOrderId"); + var named = new NamedParameterJdbcTemplate(data); + var facts = named.query(sql, Map.of("customerId", "A", "deliveryOrderId", 1L), (row, index) -> + new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(row.getLong("ledger_id"), row.getLong("dispatch_item_id"), row.getLong("pallet_id"), row.getLong("packages"), row.getObject("received_at", LocalDateTime.class))); + assertThat(facts).hasSize(1); assertThat(facts.get(0).dispatchItemId()).isEqualTo(31); + assertThat(facts.get(0).packages()).isEqualTo(4); + var delivery = new DeliveryOrder(); delivery.setPackageCount(10); + assertThat(DeliveryCargoQuantities.from(delivery, facts).remaining()).isEqualTo(6); + assertThat(named.queryForList(sql, Map.of("customerId", "OTHER", "deliveryOrderId", 1L))).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java index 2d2063fbb..4e7ef7760 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java @@ -34,6 +34,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ExceptionOrderDispatchReturnSourceTest { + @Mock private com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returnItems; @Mock private DeliveryOrderMapper deliveryOrders; @Mock private OutboundOrderMapper outboundOrders; @Mock private OutboundPalletItemMapper outboundPalletItems; @@ -50,6 +51,7 @@ class ExceptionOrderDispatchReturnSourceTest { void setUp() { service = new ExceptionOrderService(); ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "returnItems", returnItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems); ReflectionTestUtils.setField(service, "outboundPdaPicks", outboundPdaPicks); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java index 5e828e45f..efd67f94a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -119,11 +119,13 @@ class ExceptionOrderLifecycleTest { order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L); order.setDeliveryNo("DL-1"); ExceptionReturnItem source = new ExceptionReturnItem(); + source.setId(501L); source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L); source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L); DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L); + delivery.setPackageCount(2); LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30); when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null); when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); @@ -131,6 +133,7 @@ class ExceptionOrderLifecycleTest { when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class))) .thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt)); when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(returnItems.receivedByDelivery("1001", 71L)).thenReturn(2L); when(lastMileOrders.selectList(any())).thenReturn(List.of()); ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓", List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L, @@ -146,6 +149,82 @@ class ExceptionOrderLifecycleTest { verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)); } + @Test + void partialReceiptStaysProcessingAndCannotCompleteTheDelivery() { + var delivery = partialReturn(4, 10, 1); + var result = service.complete("1001", "9001", "EX-1", receipt(1)); + assertThat(result.status()).isEqualTo("PROCESSING"); + assertThat(result.completedAt()).isNull(); + assertThat(result.restoredPackages()).isEqualTo(1); + assertThat(delivery.getStatus()).isEqualTo("RETURN_PENDING"); + verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class)); + ArgumentCaptor item = ArgumentCaptor.forClass(ExceptionReturnItem.class); + verify(returnItems).updateById(item.capture()); + assertThat(item.getValue().getReceivedPackages()).isEqualTo(1); + } + + @Test + void fullyReceivingAPartialReturnCaseDoesNotCloseTheRemainingDeliveryCargo() { + var delivery = partialReturn(2, 10, 2); + var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); + delivery.setSignedAt(signedAt); + var result = service.complete("1001", "9001", "EX-1", receipt(2)); + assertThat(result.status()).isEqualTo("COMPLETED"); + assertThat(delivery.getStatus()).isEqualTo("RETURN_PENDING"); + assertThat(delivery.getSignedAt()).isEqualTo(signedAt); + verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class)); + } + + @Test + void closingCustomerReturnCaseDoesNotInventCargoHandoff() { + var order = order("PROCESSING", 1); + order.setExceptionType("RETURN"); order.setReturnDirection("CUSTOMER"); order.setDeliveryNo("DL-1"); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + var result = service.complete("1001", "9001", "EX-1", new ExceptionOrderRequests.Complete(1, "other-1", "联系客户", List.of(101L), List.of())); + assertThat(result.status()).isEqualTo("COMPLETED"); + verify(deliveryOrders, never()).updateById(any(DeliveryOrder.class)); + verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class)); + } + + @Test + void cancellingPendingReturnRestoresTheFrozenStatusAndPreservesSignature() { + var order = order("PENDING", 1); + order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1"); + order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}"); + var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L); + var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(returnItems.findByException("1001", 1L)).thenReturn(List.of()); + var result = service.cancel("1001", "9001", "EX-1", new ExceptionOrderRequests.Cancel(1, "cancel-1", "无需退回")); + assertThat(result.status()).isEqualTo("CANCELLED"); + assertThat(delivery.getStatus()).isEqualTo("DELIVERED"); + assertThat(delivery.getVersion()).isEqualTo(4); + assertThat(delivery.getSignedAt()).isEqualTo(signedAt); + verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class)); + } + + private DeliveryOrder partialReturn(long approved, int shipped, long actual) { + var order = order("PROCESSING", 1); + order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); order.setDeliveryNo("DL-1"); + order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("1"); order.setInboundOrderId(44L); + var source = new ExceptionReturnItem(); source.setId(501L); source.setOutboundPalletItemId(51L); + source.setInventoryPositionId(61L); source.setApprovedReturnPackages(approved); source.setReceivedPackages(0L); + var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setPackageCount(shipped); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source)); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(returnItems.receivedByDelivery("1001", 71L)).thenReturn(actual); + when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class))) + .thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, actual, actual, false, LocalDateTime.now())); + return delivery; + } + + private ExceptionOrderRequests.Complete receipt(long amount) { + return new ExceptionOrderRequests.Complete(1, "batch-1", "本次实际退回", List.of(101L), + List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301"))); + } + private String hashFor(Object request) { return (String) ReflectionTestUtils.invokeMethod(service, "hash", request); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java new file mode 100644 index 000000000..98373940b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.UUID; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class ExceptionReturnConcurrencyTest { + @Test void concurrentCreatorsSerializeOnTheRealDeliveryRowAndCancelledHistoryDoesNotBlock() throws Exception { + assertThat(ExceptionOrderService.class.getMethod("markReturn", String.class, String.class, String.class, String.class, + com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.MarkReturn.class) + .getAnnotation(org.springframework.transaction.annotation.Transactional.class).isolation()) + .isEqualTo(org.springframework.transaction.annotation.Isolation.READ_COMMITTED); + var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""); + var jdbc = new JdbcTemplate(data); + jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(50),delivery_no VARCHAR(50),status VARCHAR(40))"); + jdbc.execute("CREATE TABLE ll_exception_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(50),delivery_order_id BIGINT,exception_type VARCHAR(30),status VARCHAR(30))"); + jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A','DL-1','DELIVERING')"); + jdbc.update("INSERT INTO ll_exception_order VALUES(10,'A',1,'RETURN','CANCELLED')"); + var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); + var sessions = new SqlSessionTemplate(factory.getObject()); + var service = new ExceptionOrderService(); + ReflectionTestUtils.setField(service, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class)); + ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class)); + var tx = new TransactionTemplate(new DataSourceTransactionManager(data)); + tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + var locked = new CountDownLatch(1); var secondStarted = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> tx.executeWithoutResult(status -> { + service.lockForReturn("A", "DL-1"); + jdbc.update("INSERT INTO ll_exception_order VALUES(11,'A',1,'RETURN','PENDING')"); + locked.countDown(); + try { assertThat(releaseFirst.await(5, TimeUnit.SECONDS)).isTrue(); } + catch (InterruptedException error) { throw new IllegalStateException(error); } + })); + Future second = executor.submit(() -> { + try { assertThat(locked.await(3, TimeUnit.SECONDS)).isTrue(); } + catch (InterruptedException error) { throw new IllegalStateException(error); } + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + // Prime a query before the first transaction commits, just like the idempotency lookup. + jdbc.queryForObject("SELECT COUNT(*) FROM ll_exception_order", Long.class); + secondStarted.countDown(); service.lockForReturn("A", "DL-1"); + jdbc.update("INSERT INTO ll_exception_order VALUES(12,'A',1,'RETURN','PENDING')"); + })).hasMessageContaining("已存在退回异常单"); + }); + assertThat(secondStarted.await(3, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(200, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + releaseFirst.countDown(); + first.get(8, TimeUnit.SECONDS); second.get(8, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_exception_order WHERE status='PENDING'", Long.class)).isEqualTo(1); + jdbc.update("UPDATE ll_exception_order SET status='COMPLETED' WHERE id=11"); + tx.executeWithoutResult(status -> assertThat(service.lockForReturn("A", "DL-1").getId()).isEqualTo(1)); + } finally { releaseFirst.countDown(); executor.shutdownNow(); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java new file mode 100644 index 000000000..233937ce7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ReturnReceiptPolicyTest { + @Test void cumulativeReceiptCannotExceedApprovalAcrossBatches() { + var source = source(); source.setReceivedPackages(3L); + assertThat(ReturnReceiptPolicy.quantities(List.of(source), List.of(receipt(1)))).containsEntry(1L, 1L); + assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source), List.of(receipt(2)))).hasMessageContaining("累计实收"); + assertThat(ReturnReceiptPolicy.complete(List.of(source))).isFalse(); + source.setReceivedPackages(4L); assertThat(ReturnReceiptPolicy.complete(List.of(source))).isTrue(); + } + @Test void splitTargetsAreSummedBeforeCheckingTheSourceLimit() { + assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(receipt(3), receipt(2)))) + .hasMessageContaining("累计实收"); + } + @Test void wrongPositionCannotBorrowAnotherSourceApproval() { + var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L"); + assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(wrong))).hasMessageContaining("冻结来源"); + } + private ExceptionReturnItem source() { + var value = new ExceptionReturnItem(); value.setId(1L); value.setOutboundPalletItemId(10L); + value.setInventoryPositionId(11L); value.setApprovedReturnPackages(4L); value.setReceivedPackages(0L); return value; + } + private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L"); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java index bb95bc67f..a3671f2cc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java @@ -32,9 +32,9 @@ class FleetHandoverPdaQueryTest { """); when(history.selectList(any())).thenReturn(List.of(departed)); when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of( - new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")), + new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z"), List.of()), "DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)), - new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null), + new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null, List.of()), "WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null))))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java index 27e0035ef..e49e87141 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java @@ -41,14 +41,14 @@ class FleetHandoverPolicyTest { } @Test void sameDestinationCannotMixCustomerOrDispatchFacts() { - var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null); + var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null, List.of()); assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong)); } @Test void missingDuplicateAndChangedQuantityFactsAreRejected() { assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null))); assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null))); - var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null); + var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null, List.of()); assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong)); } @@ -90,7 +90,7 @@ class FleetHandoverPolicyTest { } private static Delivery delivery(Cargo cargo, Instant terminalAt) { return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(), - cargo.destinationKey(), cargo.packages(), terminalAt); + cargo.destinationKey(), cargo.packages(), terminalAt, List.of()); } private static Load load(int pallets, int kg, int cbm) { return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java index 29049855f..45c32b142 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java @@ -70,7 +70,7 @@ class FleetHandoverTransactionTest { true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null)); var inspection = mock(FleetHandoverInspectionService.class); var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10); - var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null); + var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null, List.of()); var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10, List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone")))); when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java index 141128e05..589f9155e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java @@ -63,10 +63,10 @@ class FleetTransitEvidenceTest { @Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() { var signed = LocalDateTime.of(2026, 10, 3, 9, 0); - delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed); + delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed); delivery.setSignedPackages(10); var fact = adapter.transitEvidence("PC-1", false); assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt()); - verifyNoInteractions(operations, workflow); + verify(operations).returnReceipts("CUSTOMER-A", 41L); verifyNoInteractions(workflow); } @Test void unsignedReturnRequiresImmutableReturnOperation() { @@ -74,6 +74,7 @@ class FleetTransitEvidenceTest { assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); var returned = LocalDateTime.of(2026, 10, 3, 10, 0); when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned); + when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 10, returned))); assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(), adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt()); } @@ -104,7 +105,7 @@ class FleetTransitEvidenceTest { } @Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() { - delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0)); + delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0)); delivery.setSignedPackages(10); var evidence = adapter.transitEvidence("PC-1", true); var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone"); adapter.handover(evidence, assignment); @@ -113,4 +114,37 @@ class FleetTransitEvidenceTest { assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment)); verifyNoInteractions(workflow); } + + @Test void partialReturnThenRemainingHandoverThenSignatureConservesTheOriginalShipment() { + var returnedAt = LocalDateTime.of(2026, 10, 3, 9, 0); + delivery.setStatus("RETURN_PENDING"); + when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 4, returnedAt))); + var evidence = adapter.transitEvidence("PC-1", true); + var departed = returnedAt.minusHours(1).atZone(ZoneId.systemDefault()).toInstant(); + var handedOver = returnedAt.plusMinutes(1).atZone(ZoneId.systemDefault()).toInstant(); + var load = new com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load(1, new java.math.BigDecimal("100"), java.math.BigDecimal.ONE); + var inspected = FleetHandoverPolicy.inspect(departed, handedOver, handedOver, + List.of(new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-1", "51", "61", "FBA:ONT8", 10)), + List.of(evidence.deliveries().get(0).cargo()), List.of(new FleetHandoverPolicy.Pallet("61", load)), load); + assertEquals(6, inspected.remainingCargo().get(0).packages()); + assertEquals(load, inspected.occupied()); // No invented proportional weight reduction. + assertNotNull(adapter.completion("PC-1", false).blockReason()); + when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery); + when(deliveries.update(isNull(), any())).thenReturn(1); + adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW", "司机", "phone")); + delivery.setStatus("DELIVERED"); delivery.setSignedAt(returnedAt.plusMinutes(2)); delivery.setSignedPackages(6); + var complete = adapter.completion("PC-1", false); + assertNull(complete.blockReason()); + var outcome = complete.outcomes().get(0); + assertEquals(10, outcome.packages()); assertEquals(6, outcome.signedPackages()); assertEquals(4, outcome.returnedPackages()); + assertEquals(0, outcome.remainingPackages()); + // A later return of signed goods cannot rewrite the original 6 signed + 4 returned journey. + when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of( + new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 4, returnedAt), + new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(2L, 51L, 61L, 2, returnedAt.plusMinutes(3)))); + delivery.setStatus("RETURN_PENDING"); + assertEquals(outcome, adapter.completion("PC-1", false).outcomes().get(0)); + assertEquals(10, delivery.getPackageCount()); + verifyNoInteractions(workflow); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java new file mode 100644 index 000000000..cae6c84bf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.LocalDate; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetQuoteCandidatePersistenceTest { + private FleetQuoteCandidateService candidates;private FleetQuoteService quotes;private TransactionTemplate transaction; + private final LocalDate day=LocalDate.of(2026,10,3); + @BeforeEach void setup()throws Exception{ + var source=new DriverManagerDataSource("jdbc:h2:mem:quote-candidates-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var jdbc=new JdbcTemplate(source);String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")){ + var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema);assertThat(match.find()).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); + } + var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("测试客户"); + var json=new ObjectMapper().registerModule(new JavaTimeModule());var manager=new DataSourceTransactionManager(source);transaction=new TransactionTemplate(manager); + quotes=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,json); + var proxy=new ProxyFactory(new FleetQuoteCandidateService(new FleetQuoteCandidateRepository(jdbc),directory,json));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));candidates=(FleetQuoteCandidateService)proxy.getProxy(); + } + private FleetQuoteModels.Draft draft(String kind,long customer,BigDecimal price){return new FleetQuoteModels.Draft(kind,customer,"LA","ONT8",BigDecimal.TEN,"USD",BigDecimal.ONE,BigDecimal.TEN,price,day,day,null);} + private FleetQuoteModels.Quote create(String key,String kind,long customer,BigDecimal price){return transaction.execute(s->quotes.create("1",new FleetQuoteModels.Create(key,draft(kind,customer,price))));} + private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");} + @Test void realQueryRestrictsCustomerPurposeAndDateAndDoesNotPromoteUnknownToZero(){ + create("candidate-sale","SALE",1,BigDecimal.ZERO);create("candidate-other","SALE",2,BigDecimal.ONE);create("candidate-buy","PURCHASE",1,BigDecimal.ONE); + var result=candidates.candidates(new FleetQuoteCandidateModels.Query(1L," LA ","ont8","USD",day,"PALLET",1,20)); + assertThat(result.total()).isEqualTo(1);assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0"); + assertThat(candidates.candidates(new FleetQuoteCandidateModels.Query(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET",1,20)).total()).isZero(); + } + @Test void sourceResolutionRequiresTheBookingTransactionAndKeepsOriginalVersionAfterLaterPriceChange(){ + var quote=create("candidate-source","SALE",1,BigDecimal.ZERO);var ref=new FleetQuoteCandidateModels.QuoteRef(quote.quoteNo(),quote.recordVersion(),"LOOSE_DELIVERY"); + assertThatThrownBy(()->candidates.lockAndResolve(context(),ref)).isInstanceOf(IllegalTransactionStateException.class); + var source=transaction.execute(s->candidates.lockAndResolve(context(),ref)); + transaction.executeWithoutResult(s->quotes.update(quote.quoteNo(),"1",new FleetQuoteModels.Update(quote.recordVersion(),draft("SALE",1,new BigDecimal("15.00"))))); + assertThat(source.quotedUnitPrice()).isEqualByComparingTo("0");assertThat(source.recordVersion()).isEqualTo(quote.recordVersion()); + assertThatThrownBy(()->transaction.execute(s->candidates.lockAndResolve(context(),ref))).hasMessageContaining("重新选择"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java new file mode 100644 index 000000000..9951b2ab2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetQuoteCandidateServiceTest { + private final ObjectMapper json=new ObjectMapper().registerModule(new JavaTimeModule()); + private final FleetQuoteCandidateRepository repository=mock(FleetQuoteCandidateRepository.class); + private final FleetQuoteDirectory directory=mock(FleetQuoteDirectory.class); + private final FleetQuoteCandidateService service=new FleetQuoteCandidateService(repository,directory,json); + private final LocalDate day=LocalDate.of(2026,10,3); + private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");} + private FleetQuoteModels.Draft draft(String kind,long party,String currency,BigDecimal loose){return new FleetQuoteModels.Draft(kind,party,"LA","ONT8",BigDecimal.TEN,currency,BigDecimal.TEN,BigDecimal.ONE,loose,day,day,null);} + private FleetQuoteCandidateRepository.QuoteRow row(String number,String status,long version,FleetQuoteModels.Draft d)throws Exception{return new FleetQuoteCandidateRepository.QuoteRow(number,"客户名称快照",status,version,json.writeValueAsString(d));} + private FleetQuoteCandidateModels.Query query(int size){var c=context();return new FleetQuoteCandidateModels.Query(c.customerId(),c.originCity(),c.destinationCode(),c.currency(),c.effectiveOn(),c.priceBasis(),1,size);} + @Test void candidatesKeepZeroAndBothDateBoundariesButExcludeUnknownPurchaseCustomerAndCurrency()throws Exception{ + when(repository.candidates(any())).thenReturn(List.of(row("FQ-ZERO","ACTIVE",7,draft("SALE",1,"USD",BigDecimal.ZERO)), + row("FQ-UNKNOWN","ACTIVE",1,draft("SALE",1,"USD",null)),row("FQ-PURCHASE","ACTIVE",1,draft("PURCHASE",1,"USD",BigDecimal.ONE)), + row("FQ-OTHER","ACTIVE",1,draft("SALE",2,"USD",BigDecimal.ONE)),row("FQ-CNY","ACTIVE",1,draft("SALE",1,"CNY",BigDecimal.ONE)), + row("FQ-OFF","INACTIVE",1,draft("SALE",1,"USD",BigDecimal.ONE)),row("FQ-NEXT","ACTIVE",3,draft("SALE",1,"USD",BigDecimal.TEN)))); + var result=service.candidates(query(1));assertThat(result.total()).isEqualTo(2);assertThat(result.records()).hasSize(1); + assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");assertThat(result.records().get(0).recordVersion()).isEqualTo(7); + verify(directory,times(1)).name("SALE",1L);verify(repository).candidates(new FleetQuoteCandidateModels.Context(1L,"la","ONT8","USD",day,"PALLET")); + } + @Test void selectionRejectsStaleVersionsWrongBasisAndUnavailablePrice()throws Exception{ + when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO))); + assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",7L,"LOOSE_DELIVERY"))).hasMessageContaining("重新选择"); + assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"FULL_DELIVERY"))).hasMessageContaining("计价项目"); + when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",null))); + assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"))).hasMessageContaining("尚未报价"); + } + @Test void resolvedSnapshotRetainsSourcePriceInsteadOfInventingNegotiatedAmount()throws Exception{ + when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO))); + var result=service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY")); + assertThat(result.customerId()).isEqualTo("1");assertThat(result.customerName()).isEqualTo("客户名称快照");assertThat(result.quotedUnitPrice()).isEqualByComparingTo("0"); + assertThat(result.priceItem()).isEqualTo("LOOSE_DELIVERY");assertThat(result.effectiveOn()).isEqualTo(day); + } + @Test void selectionRejectsChangedRouteCustomerCurrencyDateAndStatus()throws Exception{ + var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"); + when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE))); + for(var c:List.of(new FleetQuoteCandidateModels.Context(2L,"LA","ONT8","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"SF","ONT8","USD",day,"PALLET"), + new FleetQuoteCandidateModels.Context(1L,"LA","LAX9","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","CNY",day,"PALLET"), + new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET")))assertThatThrownBy(()->service.lockAndResolve(c,ref)).hasMessageContaining("不匹配"); + when(repository.lock("FQ")).thenReturn(row("FQ","INACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE))); + assertThatThrownBy(()->service.lockAndResolve(context(),ref)).hasMessageContaining("不匹配"); + } + @Test void selectionRequiresOwnersTransactionAndReadEndpointRequiresQuotePermission()throws Exception{ + var method=FleetQuoteCandidateService.class.getMethod("lockAndResolve",FleetQuoteCandidateModels.Context.class,FleetQuoteCandidateModels.QuoteRef.class); + assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY); + var query=PlatformFleetQuoteCandidateController.class.getMethod("candidates",FleetQuoteCandidateModels.Query.class); + assertThat(query.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:quote:list')"); + } + @Test void missingExplicitCityOrBusinessDateCannotFallBackToWarehouseNameOrSystemDate(){ + var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",0L,"LOOSE_DELIVERY"); + assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L," ","ONT8","USD",day,"PALLET"),ref)).hasMessageContaining("明确客户、路线"); + assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",null,"PALLET"),ref)).hasMessageContaining("服务日期"); + verifyNoInteractions(repository,directory); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java new file mode 100644 index 000000000..c8c0ba8c2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import org.junit.jupiter.api.BeforeEach; +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 FleetBookingQuoteContextTest { + private final FleetWarehouseClient client = mock(FleetWarehouseClient.class); + private final FleetBookingReservations reservations = mock(FleetBookingReservations.class); + private final FleetBookingQuoteContext resolver = new FleetBookingQuoteContext(client, reservations, "test-token"); + private final FleetTripEntity trip = new FleetTripEntity(); + @BeforeEach void prepare() { + trip.setWarehouseId(12L); trip.setWarehouseName("不能当城市的仓名"); + trip.setPlannedDepartureAt(LocalDateTime.parse("2026-07-01T07:30:00")); + when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("ONT8", null, "FBA:ONT8"))); + warehouse(12L, "America/Los_Angeles", List.of(new FleetWarehouseClient.Address(12L, 0L, " Los Angeles "))); + } + void warehouse(long id, String timezone, List addresses) { + when(client.activeDetail("test-token", 12L)).thenReturn(ApiResponse.success(new FleetWarehouseClient.Detail( + new FleetWarehouseClient.Identity(id, 0L, "OVERSEAS", "ACTIVE", timezone), addresses))); + } + @Test void usesWarehouseCityAndDaylightSavingZoneForTheDepartureDate() { + var value = resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET"); + assertThat(value.originCity()).isEqualTo("los angeles"); + assertThat(value.effectiveOn()).hasToString("2026-07-01"); + assertThat(value.destinationCode()).isEqualTo("ONT8"); + assertThat(value.customerId()).isEqualTo(101); + verify(client).activeDetail("test-token", 12L); + } + @Test void fixedOffsetIsUsedAsConfiguredWithoutGuessingFromCountry() { + warehouse(12, "UTC-8", List.of(new FleetWarehouseClient.Address(12L, 0L, "Los Angeles"))); + assertThat(resolver.resolve(trip, 101, "FBA:ONT8", "USD", "ORDER").effectiveOn()).hasToString("2026-06-30"); + } + @Test void rejectsUnmatchedWarehouseAndUnknownZone() { + warehouse(13, "UTC", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA"))); + assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("资料已变化"); + warehouse(12, "unknown-zone", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA"))); + assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("时区尚未明确"); + } + @Test void missingOrAmbiguousCityDoesNotFallBackToWarehouseName() { + warehouse(12, "UTC", List.of(new FleetWarehouseClient.Address(13L, 0L, "wrong warehouse"))); + assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("城市尚未明确"); + warehouse(12, "UTC", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA"), new FleetWarehouseClient.Address(12L, 0L, "SF"))); + assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("城市尚未明确"); + } + @Test void routeMustBelongToThisTripAndPrivateAddressesAreNotGuessed() { + assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:OTHER", "USD", "PALLET")).hasMessageContaining("已有卸货站"); + when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("私人地址", null, "PRIVATE:hash"))); + assertThatThrownBy(() -> resolver.resolve(trip, 101, "PRIVATE:hash", "USD", "PALLET")).hasMessageContaining("手填"); + verifyNoInteractions(client); + } + @Test void unavailableDirectoryNeverProducesInventedCriteria() { + when(client.activeDetail("test-token", 12L)).thenThrow(new IllegalStateException("network")); + assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("503"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index a1c84d7e0..74ab60280 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -42,7 +42,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(28); + assertThat(count).isEqualTo(29); verifyRefs(document, document); } @@ -63,9 +63,18 @@ class FleetTripApiContractTest { private static void verifyRefs(Object node, Map root) { if (node instanceof Map map) { if (map.get("$ref") instanceof String reference) { - assertThat(reference).startsWith("#/"); Object value = root; - for (String key : reference.substring(2).split("/")) value = ((Map) value).get(key); + String pointer=reference; + if(!reference.startsWith("#/")) { + String[] parts=reference.split("#",2); + assertThat(parts).hasSize(2); + assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml"); + try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); } + catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); } + pointer="#"+parts[1]; + } + assertThat(pointer).startsWith("#/"); + for (String key : pointer.substring(2).split("/")) value = ((Map) value).get(key); assertThat(value).as(reference).isNotNull(); } map.values().forEach(value -> verifyRefs(value, root)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 014c19c1d..0a4c54309 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -12,6 +12,19 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class FleetTripAuthorizationTest { + @Test void bookingQuoteCandidatesRequireBothTripAndQuoteReadPermissions() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformFleetBookingController.class); + var query=new FleetBookingModels.QuoteCandidatesQuery(9L,"FBA:ONT8","USD","PALLET",1,20); + for(var permission:List.of("platform:fleet:trip:list","platform:fleet:quote:list")) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",List.of(new SimpleGrantedAuthority(permission)))); + assertThatThrownBy(()->controller.quoteCandidates("TR-TEST",query)).isInstanceOf(AccessDeniedException.class); + } + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"),new SimpleGrantedAuthority("platform:fleet:quote:list")))); + controller.quoteCandidates("TR-TEST",query); + verify(context.getBean(FleetBookingService.class)).quoteCandidates("TR-TEST",query); + } finally { SecurityContextHolder.clearContext(); } + } @Test void listPermissionCannotReserveOrReleaseResources() { try (var context = new AnnotationConfigApplicationContext(Config.class)) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 9f2b847da..f9f2d93bf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -23,6 +23,11 @@ import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleReposit import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.junit.jupiter.api.Test; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.aop.framework.ProxyFactory; @@ -60,7 +65,20 @@ class FleetTripPersistenceTest { private FleetTripCompletionService completion; private FleetBookingService bookingService; private FleetBookingIncomingGateway incoming; + private FleetBookingQuoteContext quoteContext; + private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates; private Instant departure, end; + @AfterEach void clearAuthentication() { SecurityContextHolder.clearContext(); } + + private FleetQuoteCandidateModels.SourceSnapshot enableQuote() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of(new SimpleGrantedAuthority("platform:fleet:quote:list")))); + var context=new FleetQuoteCandidateModels.Context(9L,"Los Angeles","ONT8","USD",LocalDate.of(2026,10,3),"PALLET"); + when(quoteContext.resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET"))).thenReturn(context); + var source=new FleetQuoteCandidateModels.SourceSnapshot("FQ-TEST",2L,"LOOSE_DELIVERY","9","外接客户","Los Angeles","ONT8",new BigDecimal("30"),"USD","PALLET",BigDecimal.ZERO,LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),context.effectiveOn()); + when(quoteCandidates.lockAndResolve(eq(context),any())).thenReturn(source); + return source; + } + private static FleetQuoteCandidateModels.QuoteRef quoteReference(){return new FleetQuoteCandidateModels.QuoteRef("FQ-TEST",2L,"LOOSE_DELIVERY");} @BeforeEach void initialize() throws Exception { departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS); @@ -70,7 +88,8 @@ class FleetTripPersistenceTest { String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", - "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation")) { + "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation", + "ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); assertThat(match.find()).as(table).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); @@ -119,7 +138,9 @@ class FleetTripPersistenceTest { when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null)); incoming=mock(FleetBookingIncomingGateway.class); - bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming), FleetBookingService.class); + quoteContext=mock(FleetBookingQuoteContext.class); + quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class); + bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class); completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); } @@ -313,9 +334,10 @@ class FleetTripPersistenceTest { } @Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() { + var source=enableQuote(); String number=readyTrip();var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create")); + var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create",quoteReference())); var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); when(incoming.create(eq("1"),any())).thenReturn(awaiting); @@ -336,6 +358,8 @@ class FleetTripPersistenceTest { List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt")); assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22); assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00"); + assertThat(accepted.booking().quoteSource()).isEqualTo(source); + verify(quoteCandidates,times(2)).lockAndResolve(any(),eq(quoteReference())); assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); dispatch(13,"9","92"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); @@ -351,7 +375,7 @@ class FleetTripPersistenceTest { @Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() { String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one"); + var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one",null); var draft=bookingService.create(number,"1",create); assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo()); assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); @@ -374,9 +398,9 @@ class FleetTripPersistenceTest { @Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() { String number=readyTrip();var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first")); + var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first",null)); bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null)); - var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second")); + var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second",null)); assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆"); assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT"); var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1); @@ -389,6 +413,44 @@ class FleetTripPersistenceTest { } private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);} + @Test void bookingSourceIsPersistedSeparatelyFromAgreedPriceAndCanBeExplicitlyCleared() { + var source=enableQuote();String number=assignedTrip();long version=service.detail(number).trip().recordVersion(); + var request=new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",quoteReference()); + var created=bookingService.create(number,"1",request); + assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source); + assertThat(created.booking().agreedAmount()).isEqualByComparingTo("100.00"); + assertThat(bookingService.create(number,"1",request).replayed()).isTrue(); + verify(quoteCandidates,times(1)).lockAndResolve(any(),eq(quoteReference())); + assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",null))).hasMessageContaining("同一登记请求"); + var cleared=bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null)); + assertThat(cleared.booking().quoteSource()).isNull(); + assertThat(bookingService.list(number).records().get(0).quoteSource()).isNull(); + assertThat(jdbc.queryForObject("SELECT quote_source_json FROM ll_fleet_external_booking",String.class)).isNull(); + } + + @Test void referencingOrClearingQuoteRequiresQuoteReadPermissionAndRollsBack() { + String number=assignedTrip();long version=service.detail(number).trip().recordVersion(); + assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"denied-quote",quoteReference()))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("报价读取权限"); + assertThat(bookingService.list(number).records()).isEmpty(); + var source=enableQuote();var created=bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"allowed-quote",quoteReference())); + SecurityContextHolder.clearContext(); + assertThatThrownBy(()->bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null))).hasMessageContaining("报价读取权限"); + assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(created.tripRecordVersion()); + } + + @Test void confirmRevalidatesQuoteAndDoesNotReserveWhenSourceIsStale() { + var source=enableQuote();String number=readyTrip();var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var created=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"stale-quote",quoteReference())); + when(quoteCandidates.lockAndResolve(any(),any())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"报价版本已变化")); + assertThatThrownBy(()->bookingService.confirm(number,created.booking().bookingNo(),"1",new FleetBookingModels.Command(created.tripRecordVersion(),0L,"confirm-stale",null))).hasMessageContaining("报价版本已变化"); + var stored=bookingService.list(number).records().get(0); + assertThat(stored.status()).isEqualTo("DRAFT");assertThat(stored.recordVersion()).isZero();assertThat(stored.quoteSource()).isEqualTo(source); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); + verify(quoteContext,times(2)).resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET")); + } + @Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() { String number = readyTrip(); jdbc.update("DELETE FROM ll_dispatch_pda_pick"); @@ -725,17 +787,18 @@ class FleetTripPersistenceTest { jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012"); assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12"); assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); - jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9 WHERE id=2012"); + jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9,signed_packages=9 WHERE id=2012"); assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); } @Test void returnedStatusRequiresImmutableReturnCompletionEvidence() { String number = departedTrip(); markDeliveriesSigned(); - jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL WHERE id=2012"); + jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL,signed_packages=NULL WHERE id=2012"); assertThat(completion.inspection(number).canComplete()).isFalse(); LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt); + returnedCargo(10, returnedAt); jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1)); assertThat(completion.inspection(number).canComplete()).isTrue(); var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip(); @@ -790,7 +853,33 @@ class FleetTripPersistenceTest { } private void markDeliveriesSigned() { jdbc.update("UPDATE ll_outbound_order SET package_count=10"); - jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); + jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_packages=10,signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); + } + + private void returnedCargo(int quantity, LocalDateTime at) { + jdbc.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,package_count) VALUES(6001,'CLIENT-B',1012,901,10)"); + jdbc.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,exception_type,status,reason,delivery_order_id,last_mile_order_id,exception_at) VALUES(6002,'CLIENT-B','EX-RETURN','RETURN','COMPLETED','真实退回',2012,6102,?)", at); + jdbc.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id,max_return_packages,approved_return_packages,received_packages,created_at) VALUES(6003,'CLIENT-B',6002,6001,4012,10,?,?,?)", quantity, quantity, at); + jdbc.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(6005,'CLIENT-B',12,6103,6102,10)"); + jdbc.update("INSERT INTO ll_inventory_ledger(id,customer_id,customer_name,balance_id,overseas_warehouse_id,inbound_order_id,business_type,business_order_no,outbound_order_no,source_event_id,idempotency_key,before_packages,delta_packages,after_packages,occurred_at) VALUES(6004,'CLIENT-B','客户B',6101,'92',6102,'RETURN_STOCK_IN','EX-RETURN','OUT-12','RETURN_SOURCE:6001:4012','receipt-unique',0,?,?,?)", quantity, quantity, at); + } + + @Test void partialReturnCannotReleaseResourcesAndResidualSignatureClosesOnlyConservedQuantities() { + String number = departedTrip(); markDeliveriesSigned(); + jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING',signed_at=NULL,signed_packages=NULL WHERE id=2012"); + returnedCargo(4, LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); + assertThat(completion.inspection(number).canComplete()).isFalse(); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), + "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "partial-no-release", "仍有余货"))).hasMessageContaining("结案"); + var signedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); + jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED',signed_at=?,signed_packages=6 WHERE id=2012", signedAt); + var inspection = completion.inspection(number); + assertThat(inspection.canComplete()).isTrue(); + var outcome = inspection.outcomes().stream().filter(row -> row.deliveryNo().equals("DL-12")).findFirst().orElseThrow(); + assertThat(outcome.packages()).isEqualTo(10); assertThat(outcome.signedPackages()).isEqualTo(6); + assertThat(outcome.returnedPackages()).isEqualTo(4); assertThat(outcome.remainingPackages()).isZero(); + completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "partial-true-completion", "实退及余货实签核平")); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); } private String readyTrip() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java index 084c01f6c..004302dfe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java @@ -20,11 +20,15 @@ class Phase5ExceptionVisitSchemaContractTest { assertThat(normalized).contains( "ll_exception_order(", "UNIQUEKEYuk_ll_exception_no(customer_id,exception_no)", - "UNIQUEKEYuk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)", + "KEYidx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)", + "return_context_jsonJSONNULL", + "received_packagesBIGINTNOTNULLDEFAULT0", + "CHECK(received_packages>=0ANDreceived_packages<=approved_return_packages)", "KEYidx_ll_exception_inbound(customer_id,inbound_code)", "ll_customer_visit(", "UNIQUEKEYuk_ll_customer_visit_delivery(customer_id,delivery_order_id)", "KEYidx_ll_customer_visit_list(customer_id,status,delivery_completed_at)"); + assertThat(normalized).doesNotContain("UNIQUEKEYuk_ll_exception_delivery_return"); assertThat(sql).contains( "COMMENT='海外仓异常订单'", "COMMENT='派送单维度客户回访记录'"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java index f27e39a69..b4082dc01 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java @@ -42,7 +42,8 @@ class Phase5OverseasUpgradeSqlContractTest { "customer_id", "outbound_type", "lx_codes", "request_hash"); assertThat(createStatement(overseas, "ll_outbound_pallet_item")).contains("picked_package_count"); assertThat(createStatement(overseas, "ll_delivery_order")).contains( - "customer_id", "overseas_warehouse_name", "waybill_no"); + "customer_id", "overseas_warehouse_name", "waybill_no", "signed_packages"); + assertThat(normalized).contains("check(signed_packagesisnullor(signed_packages>=0andsigned_packages<=package_count))"); assertThat(createStatement(overseas, "ll_dropship_order_profile")).contains( "source_pallet_no", "manifest_version", "manifest_package_count", "manifest_fragment_count", "manifest_hash"); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index c3a120b00..2bd6e285a 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -45,6 +45,9 @@ test('车次直接成本按车次幂等并保留独立币种与不可变更正 }) test('车辆报价与实测记录保留业务去重和不可变版本', () => { + const booking = table(overseas, 'baiyue_lastleg', 'll_fleet_external_booking') + assert.match(booking, /quote_source_json JSON NULL/i) + assert.match(booking, /agreed_amount DECIMAL\(16,2\) NULL/i) const quote = table(overseas, 'baiyue_lastleg', 'll_fleet_quote') assert.match(quote, /UNIQUE KEY uk_fleet_quote_no \(quote_no\)/i) assert.match(quote, /UNIQUE KEY uk_fleet_quote_natural \(natural_key\)/i) -- 2.54.0 From 7d9ee1819622c396463fcb62c598f111563dc0d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 14:52:04 +0800 Subject: [PATCH 097/176] test(fleet): persist independent acceptance requests and current-trip evidence --- scripts/README.md | 4 + scripts/admin-test-env-modules/README.md | 8 + ...-company-cost-acceptance-contract.test.mjs | 154 ++++++++++ .../fleet-company-cost-acceptance.mjs | 202 +++++++++++++ ...leet-handover-acceptance-contract.test.mjs | 251 ++++++++++++++++ .../fleet-handover-acceptance.mjs | 277 ++++++++++++++++++ .../fleet-quotes-operations-contract.test.mjs | 179 +++++++++++ .../fleet-quotes-operations.mjs | 267 +++++++++++++++++ ...eet-trip-cost-acceptance-contract.test.mjs | 135 +++++++++ .../fleet-trip-cost-acceptance.mjs | 185 ++++++++++++ scripts/fleet-company-cost-test-env.mjs | 99 +++++++ .../fleet-quotes-test-env-contract.test.mjs | 116 ++++++++ scripts/fleet-quotes-test-env.mjs | 130 ++++++++ scripts/fleet-test-env-acceptance.mjs | 168 ++++++++++- scripts/fleet-test-env-acceptance.test.mjs | 103 ++++++- 15 files changed, 2267 insertions(+), 11 deletions(-) create mode 100644 scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-handover-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-quotes-operations.mjs create mode 100644 scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs create mode 100644 scripts/fleet-company-cost-test-env.mjs create mode 100644 scripts/fleet-quotes-test-env-contract.test.mjs create mode 100644 scripts/fleet-quotes-test-env.mjs diff --git a/scripts/README.md b/scripts/README.md index 5c7a20302..d20604286 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 + +`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=a6fd0ba3`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 + `fee-void-recovery-test-env.mjs` 是费用作废确认响应丢失的 TEST 专项入口,默认 `--plan` 零网络。执行须显式传入 `--execute --environment TEST --run-id <新批次> --customer-id <测试客户ID> --control-customer-id <另一测试客户ID> --warehouse-id <海外仓ID>`,外部 TEST/Jenkins 配置及 `FBA_CORE_JENKINS_AGENT` 提供凭据与取证节点。创建一条新仓储服务应收,经提交审核和原发布确认后,对该来源版本的 VOIDED 事件注入 ACK 丢失;原事件 DEAD 后受保护重试,核对最终作废、同命令重放、另一客户费用与完整库存不变。不写业务SQL,不使用旧费用作废;故障窗口会临时重建同镜像 Lastleg,禁止并行发布或其他共享环境写入。报告在 `reports/fee-void-<新批次>/`,请求前保存意图,失败保留样本,不自动重跑或覆盖;finally 恢复原服务、停止代理并退出会话,恢复未证明则失败。`fee-void-recovery-test-env.test.mjs` 是离线编排与失败清理检查,不能代替真实环境结果。 `test-fee-void-fault-proof.mjs` 是上述流程的内部只读证据校验器,无独立 CLI;配合 `test-event-fault-executor.mjs` 与 `test-event-fault-runtime.py` 检查原费用发布确认、精确作废事件、Finance 消费及 Lastleg 确认状态。实际运行结论只以该批次证据为准。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index f0b50d22f..471a51268 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,13 @@ # Admin 测试环境 HTTP 模块 +`fleet-handover-acceptance.mjs` 是车辆专项实际DEPART后的内部交接助手,由`../fleet-test-env-acceptance.mjs`调用;仅接受本轮已验证车次/派送及自有PDA会话,核对原DEPART/费用不变、真实剩余货物和接替资源、同键重放及旧资源分别释放。调用方必须注入发送前持久化检查点,落盘失败不发送HTTP;仅保存脱敏原命令及附件摘要。签收前导出本轮`handover-fixture.json`供页面/运营统计检查,成本验证与账号清理由同一入口编排;失败保留证据,不借旧车次或自动续跑。对应`fleet-handover-acceptance-contract.test.mjs`为离线合同。 + +`fleet-quotes-operations.mjs` 的`runQuoteSession`使用独立TEST登录及本轮客户准备,`run`只验证报价增改/状态/历史及原子CSV;`verifyOperations`另需当前轮DEPART的独立托位/货量/原币成本期望,报价通过不代表运营统计通过。后者默认只读,实测更正必须显式传入写入开关和独立幂等命名空间;不推算财务实付。由专项编排器提供持久化检查点和独占报告目录,原请求未知时不自动重造样本,结束退出自身会话。对应`fleet-quotes-operations-contract.test.mjs`不访问真实环境。 + +`fleet-trip-cost-acceptance.mjs` 是同轮实际发车后的车次直接成本内部验收助手,由车辆专项入口显式调用 `verifyFleetTripCosts(context, module, tripProof, inputs)`,不独立造车次、不修改IAM。调用方须提供真实DEPART、独立确认的自有/外接逐票托盘及托位预期、仅列表权限和无车次权限两个独立会话,并负责退出;缺前置在成本写入前停止。写入本车次两条成本并更正,最终CNY100.03、USD13.07分别按整数分和真实托位核验分摊,检查重放、历史、权限拒绝后事实不变;公司维修成本不并入。请求意图和响应通过调用方checkpoint脱敏落盘,失败保留原样本/键,不自动重跑、删除或清理。`fleet-trip-cost-acceptance-contract.test.mjs` 为离线合同,不证明真实HTTP已通过。 + +`fleet-company-cost-acceptance.mjs` 是公司车辆成本内部验收模块,由 `../fleet-company-cost-test-env.mjs` 使用独立 marker 和 TEST 会话调用,不依赖或借用 `financeFullFixtures`。自行创建专用供应商、车辆及维修/救援记录,覆盖 CNY/USD 来源匹配、未付款作废重建、凭证字节、部分/全额付款重放、超付与已付改写拒绝;客户隔离只读检索本 marker 费用未进入客户费用池,不借用其他客户查询明细或账户,避免将其他并行任务的正常变化误判为污染。要求当月已有启用 USD 汇率,缺失则停止,不修改共享汇率。报告和清理边界见脚本根索引;`fleet-company-cost-acceptance-contract.test.mjs` 为离线替身测试,不调用 TEST。 + `fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。 `fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 diff --git a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs new file mode 100644 index 000000000..bab7bab53 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { run } from './fleet-company-cost-acceptance.mjs' +import { requireReady, READY_COMMIT } from '../fleet-company-cost-test-env.mjs' + +const copy = value => value == null ? value : structuredClone(value) +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') +function fixture(fault) { + const marker = 'CONTROLLED-COMPANY-COST', calls = [], vehicles = new Map(), records = new Map(), costs = new Map(), submissions = new Map(), commands = new Map(), files = new Map() + const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'controlled-request', traceId: 'controlled-trace' }) + let sequence = 0 + const history = row => ({ recordVersion: row.cost.recordVersion, action: row.action, snapshot: copy(row.cost) }) + function save(row, action) { row.action = action; row.history.unshift(history(row)) } + function current(row) { const { cost } = row; return copy(cost) } + let supplier + const context = { marker, token: 'offline-test-only', createdResources: {}, + assert(_module, label, _response, condition) { assert.ok(condition, label) }, + apiSuccess(_module, label, result) { assert.equal(result?.http, 200, label); assert.equal(result?.body?.code, '200', label); return result.body.data }, + async request(method, raw, body, _token, headers) { + calls.push({ method, raw, body: copy(body), headers: copy(headers) }); const url = new URL(raw, 'https://fixture.invalid'), path = url.pathname + if (method === 'POST' && path === '/api/platform/v1/suppliers') { supplier = { id: '8', ...copy(body) }; return response('8') } + if (method === 'GET' && path === '/api/platform/v1/suppliers/8') return response(supplier) + if (method === 'GET' && path.includes('/master-data/exchange-rates/')) return response({ month: path.split('/').at(-1), status: fault === 'inactive exchange' ? 'INACTIVE' : 'ACTIVE', usdToCnyAverage: '7.123456', recordVersion: 0 }) + if (path.endsWith('/fee-pool')) return response(fault === 'customer pollution' && costs.size ? [{ poolItemId: 'LEAK', customerId: '9', amount: 27.31, feeItemName: marker }] : []) + if (path.endsWith('/cost-entries')) return response([]) + if (path.endsWith('/customer-accounts/9/summary')) return response({ customerId: '9', currency: 'CNY', totalReceivable: 100, receivedAmount: 30, outstandingReceivable: 70, + pendingBilling: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, asOf: new Date().toISOString() }) + if (path === '/api/platform/v1/last-leg/master-data/vehicles' && method === 'POST') { + const row = { id: '71', ...copy(body), recordVersion: 0 }; vehicles.set(row.id, row); return response(row) + } + if (path.startsWith('/api/platform/v1/last-leg/master-data/vehicles/')) { + const row = vehicles.get(path.split('/vehicles/')[1].split('/')[0]); assert.ok(row) + if (method === 'GET') return response(row) + assert.equal(method, 'PATCH'); assert.equal(body.status, 'INACTIVE') + if (fault === 'cleanup failure') return response(null, 'S0503', 503) + row.status = 'INACTIVE'; row.recordVersion++; return response(row) + } + const care = path.match(/^\/api\/platform\/v1\/last-leg\/fleet\/vehicles\/71\/records(?:\/(.*))?$/) + if (care) { + const suffix = care[1] + if (!suffix && method === 'POST') { + const recordNo = `VR-${records.size + 1}`, row = { recordNo, vehicleId: '71', vehiclePlate: vehicles.get('71').plateNumber, recordVersion: 0, details: copy(body.details) } + records.set(recordNo, row); return response(row) + } + if (suffix === 'finance-status') { + return response(body.recordNos.map(recordNo => { + const record = records.get(recordNo), row = costs.get(recordNo); assert.ok(record && row) + if (fault === 'delivery exhausted') return { recordNo, status: 'FINANCE_ATTENTION', sourceVersion: row.cost.source.sourceVersion } + if (fault === 'never delivered') return { recordNo, status: 'PENDING_FINANCE', sourceVersion: row.cost.source.sourceVersion } + const cost = current(row) + if (fault === 'wrong source vehicle') cost.source.vehicleId = 'OTHER' + if (fault === 'wrong source version') cost.source.sourceVersion = 999 + if (fault === 'wrong conversion') cost.source.amount = 999 + if (fault === 'premature paid') cost.status = 'PAID' + return { recordNo, status: cost.status, sourceVersion: cost.source.sourceVersion, submittedRecordVersion: record.recordVersion, finance: cost } + })) + } + const [recordNo, action] = suffix.split('/'), record = records.get(recordNo); assert.ok(record) + if (method === 'GET') return response(record) + if (method === 'PUT') { + if (fault === 'paid source mutable') { record.details = copy(body.details); record.recordVersion++; return response(record) } + return response(null, 'A0409') + } + assert.equal(action, 'finance') + const key = `${recordNo}:${body.idempotencyKey}` + if (submissions.has(key)) return response({ ...submissions.get(key), replayed: true }) + let row = costs.get(recordNo), sourceVersion = row ? row.cost.source.sourceVersion + 1 : 1 + if (row && row.cost.status !== 'VOIDED') return response(null, 'A0409') + const incident = record.details.incident, rate = incident.currency === 'CNY' ? 1 : 7.123456 + const source = { sourceType: record.details.recordType, sourceNo: recordNo, sourceVersion, vehicleId: '71', vehiclePlate: record.vehiclePlate, + supplierId: body.supplierId, feeName: incident.item, businessDate: body.businessDate, originalCurrency: incident.currency, + originalAmount: incident.reportedCost, exchangeRate: rate, exchangeRateMonth: body.businessDate.slice(0, 7), + amount: Math.round(incident.reportedCost * rate * 100) / 100, remark: record.details.remark } + const cost = { costNo: row?.cost.costNo ?? `VC-${++sequence}`, subjectCode: 'PLATFORM_DEFAULT', currency: 'CNY', status: 'PENDING', paidAmount: 0, outstandingAmount: null, + recordVersion: row ? row.cost.recordVersion + 1 : 0, source } + if (!row) row = { cost, payments: [], history: [] } + else row.cost = cost + save(row, 'RECEIVE'); costs.set(recordNo, row) + const result = { recordNo, sourceVersion, submittedRecordVersion: record.recordVersion, replayed: false, status: 'PENDING_FINANCE' } + submissions.set(key, copy(result)); return response(result) + } + const match = path.match(/^\/api\/platform\/finance\/v1\/vehicle-costs\/([^/]+)(?:\/(.*))?$/) + assert.ok(match, `Unexpected ${method} ${raw}`) + const [, no, action] = match, row = [...costs.values()].find(r => r.cost.costNo === no); assert.ok(row) + if (method === 'GET') { + if (!action) return response(current(row)) + const list = action === 'payments' ? row.payments : row.history + return response({ records: list, page: 1, size: 100, total: list.length, pages: list.length ? 1 : 0 }) + } + const key = `${no}:${action}:${headers?.['Idempotency-Key']}` + assert.ok(headers?.['Idempotency-Key'], 'financial commands require their stable header key') + if (commands.has(key) && !(fault === 'duplicate payment replay' && action === 'payments')) return response(current(row)) + if (Number(body.expectedVersion) !== row.cost.recordVersion && !(fault === 'duplicate payment replay' && action === 'payments')) return response(null, 'FIN_409') + if (action === 'approve') { row.cost.status = 'APPROVED'; row.cost.outstandingAmount = row.cost.source.amount; row.cost.recordVersion++; save(row, 'APPROVE') } + else if (action === 'void') { + if (row.cost.paidAmount > 0 && fault !== 'paid void allowed') return response(null, 'FIN_409') + row.cost.status = 'VOIDED'; row.cost.outstandingAmount = null; row.cost.recordVersion++; save(row, 'VOID') + } else if (action === 'payments') { + if (body.amount > row.cost.outstandingAmount && fault !== 'overpayment allowed') return response(null, 'FIN_409') + row.cost.paidAmount = Math.round((row.cost.paidAmount + body.amount) * 100) / 100 + row.cost.outstandingAmount = Math.round((row.cost.source.amount - row.cost.paidAmount) * 100) / 100 + row.cost.status = row.cost.outstandingAmount === 0 ? 'PAID' : 'PARTIALLY_PAID'; row.cost.recordVersion++ + row.payments.push({ paymentNo: `VP-${++sequence}`, currency: 'CNY', amount: body.amount, voucherFileIds: copy(body.voucherFileIds) }); save(row, 'PAY') + if (fault === 'partial money drift') row.cost.outstandingAmount += 0.01 + } else throw new Error(`Unexpected action ${action}`) + commands.set(key, true); return response(current(row)) + }, + async requestMultipart(method, raw, file) { + calls.push({ method, raw, multipart: true }); assert.ok(Buffer.from(file.content).equals(PNG)) + if (fault === 'file outage') return response(null, 'FIN_503', 503) + const fileId = id(files.size + 1); files.set(fileId, raw) + return response({ fileId, ownerScope: 'PLATFORM', tenantId: '0', businessType: 'FINANCE_VEHICLE_VOUCHER', contentType: 'image/png', sizeBytes: PNG.length }) + }, + async requestBinary(method, raw) { calls.push({ method, raw, binary: true }); return { http: 200, contentType: 'image/png', bytes: fault === 'wrong file bytes' ? Buffer.from('wrong') : PNG } }, + } + return { context, calls, vehicles, records, costs } +} +const id = value => String(value) +const options = { pollAttempts: 2, pollDelayMs: 0 } +test('independent runner requires explicit service readiness and only the specified TEST profile', () => { + const env = 'C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env' + assert.throws(() => requireReady([], [`--env-file=${env}`]), /就绪/) + assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('test-acceptance', 'dev-acceptance')}`]), /授权目录/) + assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).endsWith('test-acceptance.env')) +}) +test('company cost verifier exercises CNY/USD, void/rebuild, payment replay and customer isolation without direct transport', async () => { + const { context, calls, vehicles, records, costs } = fixture() + await run(context, options) + const proof = context.createdResources.fleetCompanyCostAcceptance + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.cleanup, 'INACTIVE') + assert.equal(proof.cases.length, 2); assert.equal(records.size, 2) + assert.ok([...costs.values()].every(row => row.cost.status === 'PAID' && row.payments.length === 2)) + assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE')) + assert.ok(calls.every(call => call.raw.startsWith('/api/platform/'))) + assert.equal(calls.some(call => /cost-entries|customer-accounts|customerId=/.test(call.raw)), false, 'must not borrow another task customer for evidence') + assert.equal(calls.some(call => call.method === 'DELETE'), false) + await assert.rejects(run(context, options), /本轮已存在/) +}) +test('missing independent marker blocks before any write', async () => { + const { context, calls } = fixture(); context.marker = '' + await assert.rejects(run(context, options), /独立marker/); assert.equal(calls.length, 0) +}) +for (const fault of ['inactive exchange', 'delivery exhausted', 'never delivered', 'wrong source vehicle', 'wrong source version', 'wrong conversion', 'premature paid', + 'duplicate payment replay', 'overpayment allowed', 'partial money drift', 'paid void allowed', 'paid source mutable', 'customer pollution', 'file outage', 'wrong file bytes', 'cleanup failure']) { + test(`company cost verifier rejects ${fault} and preserves failure evidence`, async () => { + const { context, vehicles } = fixture(fault) + await assert.rejects(run(context, options)) + const proof = context.createdResources.fleetCompanyCostAcceptance + assert.equal(proof.status, 'FAILED') + assert.ok(proof.error || proof.cleanupError) + if (vehicles.size && fault !== 'cleanup failure') assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE')) + if (fault === 'cleanup failure') assert.equal(proof.cleanup, 'FAILED') + }) +} diff --git a/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs b/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs new file mode 100644 index 000000000..cbe6b69e3 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs @@ -0,0 +1,202 @@ +import { randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' + +const MODULE = 'fleet-company-cost-acceptance' +const OPTIONS = { scenario: 'company-vehicle-cost-payment', deploymentBlocker: true } +const FINANCE = '/api/platform/finance/v1' +const MASTER = '/api/platform/v1/last-leg/master-data' +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') +const id = value => value == null ? '' : String(value) +const ok = response => response?.http === 200 && id(response?.body?.code) === '200' +function scaled(value, scale) { + if (!new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`).test(id(value))) throw new Error('验收金额或汇率格式无效') + const [whole, fraction = ''] = id(value).split('.') + return BigInt(whole) * 10n ** BigInt(scale) + BigInt(fraction.padEnd(scale, '0')) +} +const cents = value => scaled(value, 2) +const amount = value => Number(`${value / 100n}.${id(value % 100n).padStart(2, '0')}`) +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) + return value +} +const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)) + +/** Internal authorized TEST module; entrypoint owns registration, credentials and execution. No direct fetch, SQL or historical fixtures. */ +export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {}) { + if (!context.marker || !context.createdResources) throw new Error('公司车辆成本验收须有独立marker和本轮资源上下文') + if (context.createdResources.fleetCompanyCostAcceptance) throw new Error('本轮已存在公司成本验收记录;保留原证据,不自动重新造数') + if (!Number.isInteger(pollAttempts) || pollAttempts < 1 || pollAttempts > 120 || !Number.isFinite(pollDelayMs) || pollDelayMs < 0 || pollDelayMs > 5000) + throw new Error('公司成本轮询范围无效') + const proof = { marker: context.marker, status: 'CHECKING', cases: [], requests: [], cleanup: 'NOT_CREATED', isolationScope: 'Only marker-owned company fees must be absent from the customer fee pool. Customer-specific cost/account records and unrelated concurrent changes are not inspected or compared.' } + context.createdResources.fleetCompanyCostAcceptance = proof + function check(label, condition, response = null) { + context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(`${MODULE}: ${label}`) + } + function evidence(label, method, path, response) { + proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + } + async function request(method, path, body, label, { key, reject } = {}) { + const planned = { label, method, path, requestBody: body, idempotencyKey: key, state: 'PLANNED' } + proof.requests.push(planned) + await context.checkpoint?.() + const response = await context.request(method, path, body, context.token, key ? { 'Idempotency-Key': key } : {}) + Object.assign(planned, { state: 'RETURNED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) + await context.checkpoint?.() + if (reject) { + check(label, response?.http === 200 && reject.includes(id(response?.body?.code)), response) + return response.body?.data + } + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + check(label, ok(response) && data != null, response) + return data + } + async function snapshotCustomer(label) { + const pool = await request('GET', `${FINANCE}/fee-pool?direction=ALL`, undefined, `${label}只读核对公司来源未进入客户费用池`) + check('客户费用池隔离查询形状完整', Array.isArray(pool)) + // Do not retain or operate other tasks' customer records. Only count this run's unique marker. + return { companyFeePoolRows: pool.filter(row => JSON.stringify(row).includes(context.marker)).length } + } + async function page(path, label) { + const result = await request('GET', `${path}?page=1&size=100`, undefined, label) + check(`${label}分页完整`, Array.isArray(result.records) && Number(result.page) === 1 && Number(result.size) === 100 + && Number(result.total) === result.records.length && Number(result.total) <= 100) + return result.records + } + let failure + try { + const businessDate = new Date().toISOString().slice(0, 10), month = businessDate.slice(0, 7) + const exchange = await request('GET', `${MASTER}/exchange-rates/${month}`, undefined, '读取本月有效USD汇率,不修改共享汇率') + check('USD汇率月份有效', exchange.month === month && exchange.status === 'ACTIVE' && scaled(exchange.usdToCnyAverage, 6) > 0n) + proof.exchangeRate = { month, usdToCnyAverage: id(exchange.usdToCnyAverage), recordVersion: id(exchange.recordVersion) } + proof.customerBefore = await snapshotCustomer('操作前') + check('本轮marker尚无客户费用污染,禁止覆盖旧验收', proof.customerBefore.companyFeePoolRows === 0) + proof.supplierRequest = { name: `${context.marker} 公司成本专用供应商`, category: 'TRUCKING', carrierCodes: [], region: 'TEST-OVERSEAS', + legalRepresentative: '受控验收', contactName: '受控验收', phone: '00000000000', email: null, address: '受控测试地点', + businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'IMMEDIATE', remark: context.marker, truckingScope: 'OVERSEAS' } + proof.supplierId = id(await request('POST', '/api/platform/v1/suppliers?tenantId=0', proof.supplierRequest, '创建本轮独占公司成本供应商')) + check('供应商创建返回真实标识', /^[1-9]\d*$/.test(proof.supplierId)) + const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对本轮独占供应商') + check('供应商确属本轮独占样本', id(supplier.id) === proof.supplierId && supplier.status === 'ACTIVE' && supplier.remark === context.marker && supplier.name === proof.supplierRequest.name) + const vehicle = await request('POST', `${MASTER}/vehicles`, { plateNumber: `FC-${randomUUID().slice(0, 12)}`, model: 'Company cost TEST', fuelConsumptionPer100Km: 10, + mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '创建本轮公司成本专用车辆') + check('车辆返回稳定身份', Boolean(vehicle.id && vehicle.plateNumber)) + proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber; proof.cleanup = 'PENDING' + const care = `/api/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(proof.vehicleId)}/records` + for (const [recordType, currency, originalAmount] of [['REPAIR', 'CNY', 27.31], ['RESCUE', 'USD', 10]]) { + const scenario = { recordType, status: 'CHECKING', commands: [] }; proof.cases.push(scenario) + const details = { recordType, occurredAt: new Date(Date.now() - 1000).toISOString(), distanceUnit: null, odometer: null, + lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker, + incident: { description: `${context.marker} 受控验收事件`, item: `${context.marker} ${recordType}公司费用`, place: '受控测试地点', handler: '验收操作人', + processingStatus: 'COMPLETED', exceptionType: null, reportedCost: originalAmount, currency } } + const creation = { idempotencyKey: randomUUID(), details }; scenario.creation = creation + const record = await request('POST', care, creation, `创建本轮${recordType}记录`) + check('仅操作当前车辆当前运行记录', id(record.vehicleId) === proof.vehicleId && record.details?.remark === context.marker && record.details?.recordType === recordType && record.recordNo) + scenario.recordNo = record.recordNo + const recordPath = `${care}/${encodeURIComponent(record.recordNo)}` + const rate = currency === 'CNY' ? 1000000n : scaled(exchange.usdToCnyAverage, 6) + const total = (cents(originalAmount) * rate + 500000n) / 1000000n + check('受控金额可分为两笔付款', total > 1n && total <= BigInt(Number.MAX_SAFE_INTEGER)) + let submission + async function submit() { + const command = { recordVersion: record.recordVersion, supplierId: proof.supplierId, businessDate, idempotencyKey: randomUUID() } + scenario.commands.push({ action: 'SUBMIT', request: command }) + submission = await request('POST', `${recordPath}/finance`, command, '提交真实公司费用来源') + check('提交不冒充入账或付款', submission.recordNo === record.recordNo && submission.status === 'PENDING_FINANCE' && BigInt(submission.sourceVersion) > 0n) + const replay = await request('POST', `${recordPath}/finance`, command, '原请求重放提交') + check('重放保持同一来源版本', replay.replayed === true && id(replay.sourceVersion) === id(submission.sourceVersion)) + } + async function awaitFinance() { + for (let attempt = 0; attempt < pollAttempts; attempt++) { + const rows = await request('POST', `${care}/finance-status`, { recordNos: [record.recordNo] }, '轮询本条已提交Finance来源') + check('来源状态严格按本车本记录返回', Array.isArray(rows) && rows.length === 1 && rows[0].recordNo === record.recordNo) + const state = rows[0] + check('投递未进入需人工处理状态', state.status !== 'FINANCE_ATTENTION') + if (state.finance) { + const cost = state.finance, source = cost.source + check('Finance来源与提交版本、车辆、供应商及费用完全匹配', source?.sourceNo === record.recordNo && source.sourceType === recordType + && id(source.sourceVersion) === id(submission.sourceVersion) && id(source.vehicleId) === proof.vehicleId + && id(source.supplierId) === proof.supplierId && source.businessDate === businessDate && source.exchangeRateMonth === month + && source.originalCurrency === currency && cents(source.originalAmount) === cents(originalAmount) + && scaled(source.exchangeRate, 6) === rate && cents(source.amount) === total && source.remark === context.marker + && cost.subjectCode === 'PLATFORM_DEFAULT' && cost.currency === 'CNY' && Boolean(cost.costNo)) + check('首次财务接收仍未入账付款', state.status === 'PENDING' && cost.status === 'PENDING' && cents(cost.paidAmount) === 0n && cost.outstandingAmount == null) + return cost + } + check('未接收时明确等待,不接受假成功', state.status === 'PENDING_FINANCE') + if (attempt + 1 < pollAttempts) await delay(pollDelayMs) + } + throw new Error('Finance来源轮询超时,不能认定已接收或完成验收') + } + await submit(); let cost = await awaitFinance(); scenario.costNo = cost.costNo + const costPath = `${FINANCE}/vehicle-costs/${encodeURIComponent(cost.costNo)}` + async function write(action, body, label) { + const key = randomUUID(); scenario.commands.push({ action, key, request: body }) + const result = await request('POST', `${costPath}/${action}`, body, label, { key }) + const replay = await request('POST', `${costPath}/${action}`, body, `${label}原键重放`, { key }) + check(`${label}重放不重复推进`, same(result, replay)) + return result + } + async function approve() { + cost = await write('approve', { expectedVersion: cost.recordVersion, reason: '受控验收入账' }, '确认公司成本入账') + check('入账只形成未付应付', cost.status === 'APPROVED' && cents(cost.paidAmount) === 0n && cents(cost.outstandingAmount) === total) + } + await approve() + if (recordType === 'REPAIR') { + cost = await write('void', { expectedVersion: cost.recordVersion, reason: '受控验证未付款作废后重建' }, '未付款费用作废') + check('作废保留原金额且未付款', cost.status === 'VOIDED' && cents(cost.paidAmount) === 0n && cost.outstandingAmount == null) + const previousVersion = BigInt(submission.sourceVersion) + await submit(); cost = await awaitFinance() + check('作废重建保留费用号并增加来源版本', cost.costNo === scenario.costNo && BigInt(submission.sourceVersion) === previousVersion + 1n) + await approve() + } + const uploadPath = `${costPath}/vouchers` + const uploaded = await context.requestMultipart('POST', uploadPath, { name: `${recordType.toLowerCase()}-controlled-payment.png`, type: 'image/png', content: PNG }) + evidence('上传合成测试付款凭证', 'POST', uploadPath, uploaded) + const voucher = context.apiSuccess(MODULE, '上传合成测试付款凭证', uploaded, '', OPTIONS) + check('付款凭证为公司专属真实文件', ok(uploaded) && voucher?.fileId && voucher.ownerScope === 'PLATFORM' && id(voucher.tenantId) === '0' + && voucher.businessType === 'FINANCE_VEHICLE_VOUCHER' && voucher.contentType === 'image/png' && Number(voucher.sizeBytes) === PNG.length, uploaded) + scenario.voucherFileId = id(voucher.fileId) + const binary = await context.requestBinary('GET', `${costPath}/vouchers/${encodeURIComponent(voucher.fileId)}/content`) + check('付款凭证下载字节与本轮合成图片一致', binary?.http === 200 && id(binary.contentType).startsWith('image/png') && binary.bytes instanceof Uint8Array && Buffer.from(binary.bytes).equals(PNG), binary) + const partial = total / 2n + const payment = { expectedVersion: cost.recordVersion, amount: amount(partial), paidAt: new Date(Date.now() - 1000).toISOString(), + method: 'BANK_TRANSFER', referenceNo: `${context.marker}-${recordType}`, voucherFileIds: [id(voucher.fileId)], remark: '受控验收模拟线下付款凭证,不发起银行转账' } + cost = await write('payments', payment, '登记部分付款') + check('部分付款金额守恒', cost.status === 'PARTIALLY_PAID' && cents(cost.paidAmount) === partial && cents(cost.outstandingAmount) === total - partial) + await request('POST', `${costPath}/payments`, { ...payment, expectedVersion: cost.recordVersion, amount: amount(total - partial + 1n) }, '拒绝超过未付余额的付款', { key: randomUUID(), reject: ['FIN_409'] }) + const unchanged = await request('GET', costPath, undefined, '超付失败后回查余额及版本') + check('超付拒绝没有改变财务事实', same(unchanged, cost)) + cost = await write('payments', { ...payment, expectedVersion: cost.recordVersion, amount: amount(total - partial) }, '登记剩余实际付款') + check('全额付款终态金额守恒', cost.status === 'PAID' && cents(cost.paidAmount) === total && cents(cost.outstandingAmount) === 0n) + await request('POST', `${costPath}/void`, { expectedVersion: cost.recordVersion, reason: '负向验证已付款不可作废' }, '已付款作废被拒绝', { key: randomUUID(), reject: ['FIN_409'] }) + await request('PUT', recordPath, { recordVersion: record.recordVersion, reason: '负向验证已付款不可改金额', details: { ...details, incident: { ...details.incident, reportedCost: originalAmount + 1 } } }, '已付款来源修改被拒绝', { reject: ['A0409'] }) + check('已付款负向动作不改变费用', same(await request('GET', costPath, undefined, '负向动作后回查已付费用'), cost)) + const careAfter = await request('GET', recordPath, undefined, '回查运维申报金额未被覆盖') + check('负向修改不改变运维版本或金额', id(careAfter.recordVersion) === id(record.recordVersion) && cents(careAfter.details?.incident?.reportedCost) === cents(originalAmount)) + const paid = await page(`${costPath}/payments`, '核对实际付款流水') + check('两次付款重放仅留下两条不同流水及原凭证', paid.length === 2 && new Set(paid.map(row => row.paymentNo)).size === 2 + && paid.reduce((sum, row) => sum + cents(row.amount), 0n) === total && paid.every(row => row.currency === 'CNY' && same(row.voucherFileIds.map(id), [id(voucher.fileId)]))) + const history = await page(`${costPath}/history`, '核对不可变财务操作历史') + check('历史保留接收、入账、两次付款及作废过程', history.filter(row => row.action === 'PAY').length === 2 + && history.some(row => row.action === 'RECEIVE') && history.some(row => row.action === 'APPROVE') + && (recordType !== 'REPAIR' || history.some(row => row.action === 'VOID'))) + scenario.finance = cost; scenario.payments = paid; scenario.history = history; scenario.status = 'VERIFIED' + check('本轮公司费用未进入客户费用池', same(proof.customerBefore, await snapshotCustomer(`${recordType}完成后`))) + } + proof.status = 'VERIFIED' + } catch (error) { failure = error; proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error) } + finally { + if (proof.vehicleId) { + try { + const path = `${MASTER}/vehicles/${encodeURIComponent(proof.vehicleId)}` + const vehicle = await request('GET', path, undefined, '结束前读取本轮专用车辆') + const ended = await request('PATCH', `${path}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮专用车辆,保留财务与运维证据') + check('本轮专用车辆已停用', ended.status === 'INACTIVE'); proof.cleanup = 'INACTIVE' + } catch (error) { proof.cleanup = 'FAILED'; proof.cleanupError = error instanceof Error ? error.message : String(error); proof.status = 'FAILED'; failure ??= error } + } + } + if (failure) throw failure +} diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs new file mode 100644 index 000000000..7d32e6a45 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -0,0 +1,251 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { verifyFleetHandover, cleanupFleetHandoverResources } from './fleet-handover-acceptance.mjs' + +const copy = value => value == null ? value : structuredClone(value) +function fixture(fault = '') { + const marker = 'TEST-HANDOVER-RUN', now = new Date(Date.now() - 60000).toISOString() + const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', vehicleId: '11', driverId: '21', warehouseId: '92', + stops: [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:ONE' }] } + const originalTransport = { vehiclePlate: 'ORIGINAL', driverName: '原司机', driverPhone: '111' } + const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT', + recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } + const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', + status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ inboundCode: `IN-${customer}`, packageCount: 10 }] })) + const liveDispatches = dispatchDetails.map(row => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) + const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' })) + const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, + pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } } + const resources = new Map([['VEHICLE:11', 'TR-ONE'], ['DRIVER:21', 'TR-ONE']]) + const vehicles = new Map(), drivers = new Map(), probes = new Map(), calls = [] + let createdVehicles = 0, handover = null, preview = null + const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' }) + const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) + const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) } + const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId })) + const context = { + context: { async checkpointFleetHandover() {} }, + marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, + departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: 20, dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: 10 })) } } }, + assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) }, + async requestMultipart(method, path, file) { + calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0) + drivers.get('22').recordVersion = '1'; return ok({ id: '22', recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' }) + }, + async request(method, path, body, token) { + calls.push({ method, path, body: copy(body), token }) + if (path.includes('/finance-fee-sources?')) { + const customer = new URLSearchParams(path.split('?')[1]).get('customerId') + return ok([{ sourceFeeId: `FEE-${customer}`, sourceVersion: 1, sourceStatus: 'ACTIVE', feeItemCode: 'DELIVERY_FEE', direction: 'RECEIVABLE', originalCurrency: 'CNY', originalAmount: fault === 'fee-changed' && handover ? 99 : 10 }]) + } + if (path.includes('/handover-status')) { + assert.equal(token, 'picker-secret') + const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1) + const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport + return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport, + deliveries: [{ deliveryNo: `DL-${customer}`, status: 'WAIT_DELIVERY', packages: 10, terminalAt: null, + transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } }], + latestHandover: handover ? { handoverNo: handover.handoverNo } : null }) + } + if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1))) + if (path.endsWith('/master-data/vehicles') && method === 'POST') { + const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle) + } + if (path.includes('/master-data/vehicles/')) { + const id = path.match(/\/vehicles\/([^/]+)/)[1], vehicle = vehicles.get(id) + if (method === 'PATCH') { vehicle.status = body.status; vehicle.recordVersion = '1' } + return ok(vehicle) + } + if (path.endsWith('/fleet/drivers') && method === 'POST') { + const driver = { id: '22', recordVersion: '0', details: body, active: true }; drivers.set(driver.id, driver); return ok(driver) + } + if (path.includes('/fleet/drivers/')) { + const driver = drivers.get('22') + if (path.endsWith('/review')) { driver.reviewStatus = 'APPROVED'; driver.recordVersion = '2' } + if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' } + return ok(driver) + } + if (path.endsWith('/fleet/trips') && method === 'POST') { + const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] }) + } + if (path.includes('/trips/PROBE-')) { + const number = path.match(/\/trips\/([^/]+)/)[1], probe = probes.get(number) + if (path.endsWith('/assignment')) { + if (resources.has(`VEHICLE:${body.vehicleId}`)) return conflict('车辆已被车次占用') + if (resources.has(`DRIVER:${body.driverId}`)) return conflict('司机已被车次占用') + resources.set(`VEHICLE:${body.vehicleId}`, number); resources.set(`DRIVER:${body.driverId}`, number) + Object.assign(probe, { vehicleId: body.vehicleId, driverId: body.driverId, recordVersion: '1' }) + } + if (path.endsWith('/cancel')) { + for (const [resource, owner] of resources) if (owner === number) resources.delete(resource) + probe.status = 'CANCELLED' + } + return ok({ trip: probe, dispatches: [] }) + } + if (path.includes('/history?')) { + const departure = copy(originalHistory) + if (handover && fault === 'rewritten-departure') departure.snapshot.trip.vehiclePlate = 'WRONG' + const records = handover ? [departure, { action: 'HANDOVER', recordVersion: '8' }] : [departure] + return ok({ records, total: records.length }) + } + if (path.endsWith('/handovers/preview')) { + preview = { cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId, + palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: ['PALLET-INTERNAL'], occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 } } } + if (fault === 'wrong-customer') preview.cargo.remainingCargo[0].customerId = 'OTHER' + if (fault === 'double-count-pallet') preview.cargo.occupied.palletSpaces = 2 + return ok(preview) + } + if (path.endsWith('/handovers') && method === 'POST') { + if (handover && body.idempotencyKey === proof.handover.confirmRequest.idempotencyKey) return ok(handover) + if (String(body.selection.recordVersion) !== trip.recordVersion) { + if (fault === 'stale-accepted') return ok(handover) + return conflict(fault === 'unrelated-conflict' ? '费用正在处理中' : '车次已变化,请刷新后重新核对') + } + handover = { handoverNo: 'HO-REAL', tripNo: trip.tripNo, recordVersion: '0', snapshot: { + previous: { vehicleId: '11', driverId: '21', ...originalTransport }, replacement: { vehicleId: '12', driverId: '22', vehiclePlate: vehicles.get('12').plateNumber, driverName: '接替司机', driverPhone: '222' }, cargo: preview.cargo }, vehicleRelease: null, driverRelease: null } + Object.assign(trip, handover.snapshot.replacement); bump() + resources.set('VEHICLE:12', 'TR-ONE'); resources.set('DRIVER:22', 'TR-ONE') + if (fault === 'unknown-confirmation') throw new Error('connection lost after commit') + return ok(handover) + } + if (path.includes('/handovers?')) return ok({ records: handover ? [handover] : [], total: handover ? 1 : 0 }) + if (path.endsWith('/resources/release')) { + const vehicle = body.resourceKind === 'VEHICLE', field = vehicle ? 'vehicleRelease' : 'driverRelease', resource = vehicle ? 'VEHICLE:11' : 'DRIVER:21' + if (handover[field]) { + if (fault === 'replay-releases-next') resources.delete(resource) + return ok(handover) + } + assert.equal(String(body.recordVersion), handover.recordVersion) + resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location } + handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump(); return ok(handover) + } + if (path.endsWith('/trips/TR-ONE') && method === 'GET') return ok({ trip, dispatches: bindings }) + throw new Error(`Unexpected test route ${method} ${path}`) + }, + } + return { context, proof, dispatchDetails, calls, trip, resources, probes, vehicles, drivers } +} + +test('fresh real departure carries all customer cargo, preserves DEPART and fees, and verifies both old-resource releases', async () => { + const f = fixture(), result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) + assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaReadCount, 4) + assert.equal(result.browser.originalVehiclePlate, 'ORIGINAL'); assert.match(result.browser.replacementVehiclePlate, /^HO-/) + assert.equal(f.proof.vehicleId, '12'); assert.equal(f.proof.driverId, '22'); assert.equal(f.proof.licenseFileId, 'TEST-LICENSE-FILE') + assert.equal(result.replacement.certificate.synthetic, true); assert.ok(result.replacement.certificate.contentBase64) + assert.doesNotMatch(JSON.stringify(result), /picker-secret/) + assert.ok(f.calls.filter(row => row.assertion).every(row => row.passed)) + assert.ok(!f.calls.some(row => row.path?.endsWith('/depart') || row.path?.endsWith('/complete') || row.path?.endsWith('/cancel'))) + assert.equal(f.resources.get('VEHICLE:11'), 'PROBE-1'); assert.equal(f.resources.get('DRIVER:21'), 'PROBE-1') +}) + +test('marker mismatch, unregistered copied proof and missing actual departure fail without HTTP', async () => { + for (const fault of ['marker', 'unregistered', 'departure']) { + const f = fixture(); let proof = f.proof + if (fault === 'marker') f.context.createdResources.fleetAcceptance.marker = 'ANOTHER-RUN' + if (fault === 'unregistered') proof = structuredClone(proof) + if (fault === 'departure') delete f.context.createdResources.fleetAcceptance.departure + await assert.rejects(verifyFleetHandover(f.context, 'test', proof, 'picker-secret', f.dispatchDetails), /本轮|当前marker/) + assert.equal(f.calls.length, 0) + } +}) + +test('unknown successful write preserves the original command and blocks automatic rerun and cleanup', async () => { + const f = fixture('unknown-confirmation') + const saved = [] + f.context.context.checkpointFleetHandover = async proof => saved.push(copy(proof)) + await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /connection lost/) + assert.equal(f.proof.handover.status, 'FAILED'); assert.ok(f.proof.handover.confirmRequest.idempotencyKey) + assert.equal(f.proof.handover.requests.at(-1).status, 'UNKNOWN_RESULT') + const sending = saved.find(row => row.requests.at(-1).path.endsWith('/handovers') && row.requests.at(-1).status === 'SENDING') + assert.deepEqual(sending.requests.at(-1).body, f.proof.handover.confirmRequest) + assert.equal(saved.at(-1).requests.at(-1).status, 'UNKNOWN_RESULT') + assert.ok(!JSON.stringify(saved).includes('picker-secret')) + const calls = f.calls.length + await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /不自动续跑/) + await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /仅允许/) + assert.equal(f.calls.length, calls); assert.equal(f.proof.vehicleId, '11') +}) + +test('missing persistence and failed pre-send checkpoints cannot send HTTP', async () => { + const missing = fixture(); delete missing.context.context.checkpointFleetHandover + await assert.rejects(verifyFleetHandover(missing.context, 'test', missing.proof, 'picker-secret', missing.dispatchDetails), /持久化/) + assert.equal(missing.calls.length, 0) + for (const target of ['vehicle', 'multipart']) { + const f = fixture() + let blocked + f.context.context.checkpointFleetHandover = async proof => { + const entry = proof.requests.at(-1) + if (entry.status === 'SENDING' && (target === 'multipart' ? entry.file : entry.method === 'POST')) { + blocked = copy(entry); throw Error('disk full') + } + } + await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /disk full/) + assert.ok(blocked) + assert.ok(!f.calls.some(row => row.method === blocked.method && row.path === blocked.path)) + if (target === 'multipart') { + assert.match(blocked.file.sha256, /^[a-f0-9]{64}$/); assert.ok(blocked.file.bytes > 0) + assert.equal(blocked.file.content, undefined) + } + } +}) + +test('every HTTP call observes a completed pre-send checkpoint; post-response disk failure stops the chain', async () => { + const f = fixture(), original = f.context.request.bind(f.context), saved = [] + f.context.context.checkpointFleetHandover = async proof => { + const entry = proof.requests.at(-1) + if (entry.status === 'RESPONDED' && entry.method === 'POST') throw Error('response disk failure') + saved.push(copy(proof)) + } + f.context.request = async (...args) => { + const entry = saved.at(-1).requests.at(-1) + assert.equal(entry.status, 'SENDING'); assert.equal(entry.path, args[1]); assert.deepEqual(entry.body, args[2]) + return original(...args) + } + await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /response disk failure/) + assert.equal(f.calls.filter(row => row.method === 'POST').length, 1) + assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING') +}) + +for (const [fault, message] of Object.entries({ 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', + 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', + 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { + test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { + const f = fixture(fault) + await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), new RegExp(message)) + assert.equal(f.proof.handover.status, 'FAILED'); assert.ok(f.proof.handover.requests.length > 0) + assert.ok(!f.calls.some(row => row.path?.endsWith('/cancel'))) + }) +} + +test('cleanup cannot run before genuine cargo completion and current-resource release', async () => { + const f = fixture(); await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) + const before = f.calls.length + await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /整车结案/) + assert.ok(f.calls.slice(before).filter(row => row.method).every(row => row.method === 'GET')) + assert.equal(f.proof.handover.cleanup.status, 'FAILED') +}) + +test('missing diagnostic correlation blocks the run before creating replacement resources', async () => { + const f = fixture(), original = f.context.request.bind(f.context) + f.context.request = async (...args) => { const response = await original(...args); delete response.traceId; return response } + await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /读取真实在途车次/) + assert.equal(f.vehicles.size, 0); assert.equal(f.proof.handover.requests.length, 1) + assert.equal(f.proof.handover.requests[0].status, 'RESPONDED') +}) + +test('explicit closure cleanup cancels only test probes and disables newly created resources without deletion', async () => { + const f = fixture(); await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) + f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() } + f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22') + const result = await cleanupFleetHandoverResources(f.context, 'test', f.proof) + assert.equal(result.status, 'COMPLETED'); assert.ok([...f.probes.values()].every(row => row.status === 'CANCELLED')) + assert.ok([...f.vehicles.values()].every(row => row.status === 'INACTIVE')); assert.equal(f.drivers.get('22').active, false) + assert.ok(!f.calls.some(row => row.method === 'DELETE')) +}) + +test('internal helper has no environment loader, SQL or standalone execution path', async () => { + const source = await readFile(new URL('./fleet-handover-acceptance.mjs', import.meta.url), 'utf8') + assert.doesNotMatch(source, /process\.env|process\.argv|fetch\(|execFile|INSERT INTO|UPDATE ll_|DELETE FROM/) + assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails\)/) +}) diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs new file mode 100644 index 000000000..639c0d01b --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -0,0 +1,277 @@ +import { createHash, randomUUID } from 'node:crypto' + +const FLEET = '/api/platform/v1/last-leg/fleet' +const VEHICLES = '/api/platform/v1/last-leg/master-data/vehicles' +const DISPATCHES = '/api/platform/v1/last-leg/dispatches' +const OPTIONS = { scenario: 'fleet-in-transit-handover', deploymentBlocker: true } +const text = value => value == null ? '' : String(value) +const key = () => `handover-${randomUUID()}` +const clone = value => value == null ? value : structuredClone(value) +const stable = value => JSON.stringify(canonical(value)) +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical) + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(name => [name, canonical(value[name])])) + return value +} +const same = (left, right) => stable(left) === stable(right) +const validVersion = value => (typeof value !== 'number' || Number.isSafeInteger(value)) && /^(0|[1-9]\d*)$/.test(text(value)) +const transport = value => ({ vehiclePlate: value?.vehiclePlate ?? null, driverName: value?.driverName ?? null, driverPhone: value?.driverPhone ?? null }) +const count = value => Number.isSafeInteger(Number(value)) && Number(value) > 0 +const trace = response => ({ http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) +const pathFor = number => `${FLEET}/trips/${encodeURIComponent(number)}` +const certificate = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +function requireRun(context, tripProof) { + const run = context.createdResources?.fleetAcceptance + if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker + || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo + || run.departure?.tripNo !== tripProof.tripNo || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt) + throw new Error('在途交接仅接受当前marker登记的原车次及真实DEPART结果,禁止借用旧样本') + return run +} +function io(context, module, proof) { + const checkpoint = context.context?.checkpointFleetHandover + if (typeof checkpoint !== 'function') throw new Error('交接验收必须注入发送前持久化检查点') + const check = (label, passed, response = null, evidence = '') => { + context.assert(module, label, response, Boolean(passed), evidence, OPTIONS) + if (!passed) throw new Error(`${module}: ${label}`) + } + async function request(method, path, body, label, { token, conflict, file } = {}) { + const entry = { method, path, label, actor: token ? 'PDA' : 'PLATFORM', body: clone(body), status: 'SENDING' } + if (file) entry.file = { name: file.name, type: file.type, sha256: createHash('sha256').update(file.content).digest('hex'), bytes: file.content.length } + proof.requests.push(entry) + // A failed write must stop before HTTP. Keep the durable SENDING record if the process dies in flight. + await checkpoint(proof) + try { + const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token) + Object.assign(entry, trace(response), { status: 'RESPONDED', result: clone(response?.body?.data) }) + await checkpoint(proof) + const accepted = conflict ? (response?.http === 409 || (response?.http === 200 && text(response?.body?.code) === 'A0409')) + && conflict.test(text(response?.body?.message)) : response?.http === 200 && text(response?.body?.code) === '200' + check(label, accepted && response?.requestId && response?.traceId, response, stable(trace(response))) + return response.body?.data + } catch (error) { + if (entry.status === 'SENDING') { + entry.status = 'UNKNOWN_RESULT' + await checkpoint(proof) + } + throw error + } + } + return { check, request } +} +function dispatchFacts(row) { + const fields = ['dispatchNo', 'customerId', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', + 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] + return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) +} +function feeFacts(rows) { + if (!Array.isArray(rows)) throw new Error('费用来源不是正式列表') + return rows.map(row => Object.fromEntries(['sourceFeeId', 'sourceVersion', 'sourceStatus', 'feeItemCode', 'direction', 'originalAmount', 'originalCurrency'] + .map(name => [name, row[name]]))).sort((a, b) => text(a.sourceFeeId).localeCompare(text(b.sourceFeeId))) +} +function inspectionFacts(value) { + return { ...value, remainingPalletIds: [...(value?.remainingPalletIds || [])].sort() } +} +function thousandths(value) { + const match = /^(\d+)(?:\.(\d{1,3}))?$/.exec(text(value)) + if (!match) throw new Error('实测载荷不是有效的三位小数') + return BigInt(match[1]) * 1000n + BigInt((match[2] || '').padEnd(3, '0')) +} + +/** Internal fresh-run helper, called only after the caller's real departure + replay and before signing. */ +export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails) { + const run = requireRun(context, tripProof) + if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length) + throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑') + const numbers = dispatchDetails.map(row => row.dispatchNo) + if (new Set(numbers).size !== numbers.length || numbers.some(number => !number) + || !same([...numbers].sort(), run.departure.dispatches?.map(row => row.dispatchNo).sort()) + || !dispatchDetails.every(row => count(row.packageCount)) + || Number(run.departure.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.packageCount), 0)) + throw new Error('交接输入排车清单及箱数未覆盖本轮真实发车') + const proof = tripProof.handover = { marker: context.marker, fixtureMarker: tripProof.marker, status: 'CHECKING', requests: [], probes: [], + dispatchNos: [...numbers], stockEvidence: 'CALLER_FULL_CHAIN_INVENTORY_AUDIT_REQUIRED' } + const { check, request } = io(context, module, proof), base = pathFor(tripProof.tripNo) + try { + const detail = await request('GET', base, undefined, '读取真实在途车次及分配资源') + check('真实车次属于本轮且已经发车', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'IN_TRANSIT' + && detail.trip.departedAt === run.departure.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId) + && text(detail.trip.driverId) === text(tripProof.driverId) && text(detail.trip.warehouseId) === text(tripProof.warehouseId) + && validVersion(detail.trip.recordVersion) && same(detail.dispatches?.map(row => row.dispatchNo).sort(), [...numbers].sort())) + const initialHistory = await request('GET', `${base}/history?page=1&size=200`, undefined, '读取不可变原DEPART证据') + check('原始发车历史唯一且没有此前交接', Array.isArray(initialHistory?.records) + && Number(initialHistory.total) === initialHistory.records.length + && initialHistory.records.filter(row => row.action === 'DEPART').length === 1 && !initialHistory.records.some(row => row.action === 'HANDOVER')) + proof.originalDeparture = clone(initialHistory.records.find(row => row.action === 'DEPART')) + const original = proof.originalDeparture.snapshot + check('原DEPART装载确属本车及当前分配', original?.trip?.tripNo === tripProof.tripNo && original.trip.departedAt === detail.trip.departedAt + && text(original.trip.vehicleId) === text(detail.trip.vehicleId) && text(original.trip.driverId) === text(detail.trip.driverId) + && original.confirmedLoad?.cargo?.length > 0 && original.confirmedLoad?.pallets?.length > 0) + proof.previous = { vehicleId: text(detail.trip.vehicleId), driverId: text(detail.trip.driverId), ...transport(detail.trip) } + proof.dispatchBefore = []; proof.pdaBefore = []; proof.feesBefore = [] + for (const expected of dispatchDetails) { + const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') + const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) + check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED' + && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) + && text(row.customerId) === text(binding.customerId) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) + proof.dispatchBefore.push(dispatchFacts(row)) + const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) + check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo + && pda.latestHandover == null && same(transport(pda.originalDeparture), transport(original.trip)) && pda.deliveries?.length > 0 + && pda.deliveries.every(item => !item.terminalAt && item.status === 'WAIT_DELIVERY' && same(transport(item.transport), transport(detail.trip))) + && pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.packageCount)) + proof.pdaBefore.push(pda) + for (const delivery of pda.deliveries) { + const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(binding.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}` + proof.feesBefore.push({ path, facts: feeFacts(await request('GET', path, undefined, '冻结本次派送费用来源')) }) + } + } + const capacity = { maxPalletSpaces: detail.trip.maxPalletSpaces, maxLoadKg: detail.trip.maxLoadKg, maxVolumeCbm: detail.trip.maxVolumeCbm } + check('使用正式原车辆容量及有效任务结束时间', Object.values(capacity).every(value => Number(value) > 0 && Number.isFinite(Number(value))) + && Date.parse(detail.trip.expectedDriverEndAt) > Date.now()) + const marker = `HO-${randomUUID().slice(0, 12)}` + const vehicleBody = { plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, + region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', ...capacity, remark: `${context.marker} controlled TEST handover vehicle` } + proof.replacement = { marker, vehicleRequest: vehicleBody } + const vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆') + check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker) + proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber + const driver = await request('POST', `${FLEET}/drivers`, { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), + remark: `${context.marker} TEST credential fixture; not a real driving licence` }, '建立本轮接替司机档案') + check('接替司机返回身份和版本', driver?.id && validVersion(driver.recordVersion)) + proof.replacement.driverId = text(driver.id) + const file = { name: `${marker}-TEST-NOT-REAL-LICENSE.png`, type: 'image/png', content: certificate } + proof.replacement.certificate = { name: file.name, type: file.type, contentBase64: certificate.toString('base64'), sha256: createHash('sha256').update(certificate).digest('hex'), synthetic: true } + const uploaded = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/license?recordVersion=${driver.recordVersion}`, undefined, '正式上传非真实测试证照', { file }) + check('测试证照有正式文件归属', uploaded?.licenseFileId && validVersion(uploaded.recordVersion)) + proof.replacement.licenseFileId = text(uploaded.licenseFileId) + const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机') + check('接替司机已正式审核', reviewed?.reviewStatus === 'APPROVED') + proof.selection = { recordVersion: detail.trip.recordVersion, vehicleId: vehicle.id, driverId: driver.id, + expectedDriverEndAt: detail.trip.expectedDriverEndAt, handedOverAt: new Date().toISOString() } + const preview = await request('POST', `${base}/handovers/preview`, proof.selection, '预览真实剩余货物及资源资格') + const expectedCargo = original.confirmedLoad.cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId, + palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })) + check('预览精确包含全部未签收客户货物且资格有效', /^[0-9a-f]{64}$/.test(text(preview?.cargoHash)) && preview.driverQualification?.canDispatch === true + && same(preview.cargo?.remainingCargo, expectedCargo) && preview.cargo.completedDeliveries?.length === 0 + && same([...preview.cargo.remainingPalletIds].sort(), [...new Set(expectedCargo.map(row => row.palletId))].sort())) + const measured = original.confirmedLoad.pallets + check('剩余载荷沿用原DEPART实测且共享实托只计一次', new Set(measured.map(row => row.warehousePalletId)).size === measured.length + && same(measured.map(row => row.warehousePalletId).sort(), [...preview.cargo.remainingPalletIds].sort()) + && Number(preview.cargo.occupied?.palletSpaces) === measured.reduce((sum, row) => sum + Number(row.palletSpaces), 0) + && thousandths(preview.cargo.occupied?.weightKg) === measured.reduce((sum, row) => sum + thousandths(row.weightKg), 0n) + && thousandths(preview.cargo.occupied?.volumeCbm) === measured.reduce((sum, row) => sum + thousandths(row.volumeCbm), 0n)) + proof.preview = preview + proof.confirmRequest = { selection: proof.selection, cargoHash: preview.cargoHash, location: `${context.marker}受控交接点`, reason: '本轮真实发运后整车资源交接', idempotencyKey: key() } + const handover = await request('POST', `${base}/handovers`, proof.confirmRequest, '确认实际在途交接') + check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && validVersion(handover.recordVersion) + && text(handover.snapshot?.previous?.vehicleId) === proof.previous.vehicleId && text(handover.snapshot.previous.driverId) === proof.previous.driverId + && text(handover.snapshot.replacement.vehicleId) === text(vehicle.id) && text(handover.snapshot.replacement.driverId) === text(driver.id) + && same(inspectionFacts(handover.snapshot.cargo), inspectionFacts(preview.cargo)) && handover.vehicleRelease == null && handover.driverRelease == null) + proof.handoverNo = handover.handoverNo; proof.confirmed = handover + const persisted = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化交接事实') + check('只生成一份持久化交接记录', Number(persisted?.total) === 1 && persisted.records?.[0]?.handoverNo === handover.handoverNo) + proof.persisted = persisted.records[0] + const replay = await request('POST', `${base}/handovers`, proof.confirmRequest, '原交接请求重放') + check('重放返回同一持久化记录而不重复交接', same(replay, proof.persisted)) + const after = await request('GET', base, undefined, '交接后读取车次状态和当前资源') + check('交接保持在途且版本只增加一次', after.trip?.status === 'IN_TRANSIT' && after.trip.departedAt === detail.trip.departedAt + && BigInt(text(after.trip.recordVersion)) === BigInt(text(detail.trip.recordVersion)) + 1n + && text(after.trip.vehicleId) === text(vehicle.id) && text(after.trip.driverId) === text(driver.id)) + proof.staleRequest = { ...clone(proof.confirmRequest), idempotencyKey: key() } + await request('POST', `${base}/handovers`, proof.staleRequest, '陈旧版本交接必须拒绝', { conflict: /车次.*(变化|修改)|版本/ }) + check('陈旧命令没有改变车次版本或资源', same(await request('GET', base, undefined, '拒绝后核对车次零变更'), after)) + const firstProbe = await createProbe(1) + const assignment = { recordVersion: firstProbe.recordVersion, vehicleId: proof.previous.vehicleId, driverId: proof.previous.driverId, reason: '本轮旧资源占用核对' } + await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ }) + const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release` + proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() } + const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务') + check('原车辆已结束而原司机仍在任务中', vehicleReleased.vehicleRelease?.actualEndAt && vehicleReleased.driverRelease == null) + await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ }) + proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() } + const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务') + check('原车辆司机分别保留结束事实', released.vehicleRelease?.actualEndAt && released.driverRelease?.actualEndAt) + const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划') + firstProbe.recordVersion = assigned.trip.recordVersion + check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId) + const persistedReleases = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化资源结束记录') + const releaseSnapshot = persistedReleases.records?.find(row => row.handoverNo === proof.handoverNo) + check('原车辆结束重放不改记录', same(await request('POST', releaseBase, proof.vehicleReleaseRequest, '新计划占用后重放旧车辆结束请求'), releaseSnapshot)) + check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot)) + const secondProbe = await createProbe(2) + await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ }) + const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10)}` } + const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆') + check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id) + await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ }) + for (const expected of proof.dispatchBefore) check('交接不改变原排车出库和货物归属', same(dispatchFacts(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对排车原始出库事实')), expected)) + proof.pdaAfter = [] + for (const previous of proof.pdaBefore) { + const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}/handover-status`, undefined, 'PDA只读核对接替资源与原始发车', { token: pickerToken }) + check('PDA保持原发车资源并展示每票接替资源', current.dispatchNo === previous.dispatchNo && current.tripNo === tripProof.tripNo + && same(current.originalDeparture, previous.originalDeparture) && current.latestHandover?.handoverNo === proof.handoverNo + && same(current.deliveries.map(({ transport: ignored, ...rest }) => rest), previous.deliveries.map(({ transport: ignored, ...rest }) => rest)) + && current.deliveries.every(row => same(transport(row.transport), transport(handover.snapshot.replacement)))) + proof.pdaAfter.push(current) + } + for (const fees of proof.feesBefore) check('交接不新增或改写派送费用', same(feeFacts(await request('GET', fees.path, undefined, '交接后核对原费用来源')), fees.facts)) + const historyAfter = await request('GET', `${base}/history?page=1&size=200`, undefined, '交接完成后核对原DEPART不变') + check('只新增一次交接且原DEPART内容保持不变', historyAfter.records?.filter(row => row.action === 'HANDOVER').length === 1 + && same(historyAfter.records.filter(row => row.action === 'DEPART'), [proof.originalDeparture])) + const final = await request('GET', base, undefined, '保存当前接替资源供后续真实签收结案') + proof.finalTrip = final.trip; proof.requestCount = proof.requests.length + proof.pdaReadCount = proof.requests.filter(row => row.actor === 'PDA').length + proof.browser = { tripNo: tripProof.tripNo, dispatchNos: numbers, handoverNo: proof.handoverNo, + originalVehiclePlate: proof.previous.vehiclePlate, replacementVehiclePlate: handover.snapshot.replacement.vehiclePlate, + certificateFileId: proof.replacement.licenseFileId, expectedPdaReadsPerOpenedDispatch: 1 } + proof.status = 'VERIFIED' + // Caller finishFleetTrip must finish the currently assigned resources, not the original pair. + tripProof.vehicleId = proof.replacement.vehicleId; tripProof.driverId = proof.replacement.driverId; tripProof.licenseFileId = proof.replacement.licenseFileId + return proof + } catch (error) { proof.status = 'FAILED'; proof.failure = { message: error.message, requestCount: proof.requests.length }; throw error } + + async function createProbe(index) { + const saved = { marker: context.marker, index }; proof.probes.push(saved) + const value = await request('POST', `${FLEET}/trips`, { warehouseId: tripProof.warehouseId, idempotencyKey: key(), + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), + stops: tripProof.stops, remark: `${context.marker}在途交接资源验证无货计划${index}` }, '创建本轮无货资源核对计划') + check('核对计划不承载货物且尚未发车', value?.trip?.tripNo && value.trip.status === 'PLANNED' && value.dispatches?.length === 0 && validVersion(value.trip.recordVersion)) + saved.tripNo = value.trip.tripNo; saved.recordVersion = value.trip.recordVersion; return saved + } +} + +/** Optional explicit cleanup, only after the caller signed every shipment and released the current trip's resources. */ +export async function cleanupFleetHandoverResources(context, module, tripProof) { + requireRun(context, tripProof) + const proof = tripProof.handover + if (proof?.status !== 'VERIFIED' || proof.marker !== context.marker || proof.cleanup) + throw new Error('仅允许已验证本轮交接的一次性结案后清理;失败不自动重试') + const cleanup = proof.cleanup = { status: 'CHECKING', requests: [] }, { check, request } = io(context, module, cleanup) + try { + const current = await request('GET', pathFor(tripProof.tripNo), undefined, '清理前核对整车货物结案及当前资源释放') + check('整车结案且接替车辆司机均已释放', current.trip?.status === 'COMPLETED' && current.trip.vehicleRelease && current.trip.driverRelease + && text(current.trip.vehicleId) === proof.replacement.vehicleId && text(current.trip.driverId) === proof.replacement.driverId) + for (const probe of [...proof.probes].reverse()) { + const path = pathFor(probe.tripNo), detail = await request('GET', path, undefined, '核对仅本轮无货资源计划') + check('仅取消本轮未执行无货计划', detail.trip?.tripNo === probe.tripNo && detail.trip.status === 'PLANNED' && detail.trip.remark?.includes(context.marker) && detail.dispatches?.length === 0) + const cancelled = await request('POST', `${path}/cancel`, { recordVersion: detail.trip.recordVersion, reason: '本轮交接验证完成,取消无货核对计划' }, '取消本轮资源核对计划') + check('无货计划已取消', cancelled.trip?.status === 'CANCELLED') + } + for (const vehicleId of [proof.replacement.vehicleId, proof.spareVehicleId]) { + const vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(vehicleId)}`, undefined, '读取本轮接替或隔离车辆版本') + check('停用目标确属本轮新建车辆', vehicle.remark?.includes(context.marker) && text(vehicle.id) === vehicleId) + const stopped = await request('PATCH', `${VEHICLES}/${encodeURIComponent(vehicleId)}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮新增车辆并保留记录') + check('本轮车辆已停用', stopped.status === 'INACTIVE') + } + const driverPath = `${FLEET}/drivers/${encodeURIComponent(proof.replacement.driverId)}` + const driver = await request('GET', driverPath, undefined, '读取本轮接替司机版本') + check('停用司机确属本轮受控档案', driver.details?.remark?.includes(context.marker) && text(driver.id) === proof.replacement.driverId) + const stopped = await request('PATCH', `${driverPath}/status`, { recordVersion: driver.recordVersion, active: false, reason: '本轮完整验收结束,保留测试证照与审计记录' }, '停用本轮接替司机') + check('本轮接替司机已停用', stopped.active === false) + cleanup.status = 'COMPLETED'; return cleanup + } catch (error) { cleanup.status = 'FAILED'; cleanup.failure = error.message; throw error } +} diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs new file mode 100644 index 000000000..91822a415 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { run, runQuoteSession, verifyOperations, requireCurrentFixture, readCsv, fixtureContract } from './fleet-quotes-operations.mjs' +import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' + +const copy = value => structuredClone(value) +function harness(fault) { + const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' + const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = [] + let next = 0, supplierStatus = 'ACTIVE' + let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null } } + const measuredHistory = [] + const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] } + const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' }) + function snapshot(row, action) { histories.get(row.quoteNo).unshift({ action, recordVersion: row.recordVersion, snapshot: copy(row) }) } + const natural = d => JSON.stringify([d.kind, d.partyId, d.originCity, d.destinationCode, d.distanceMiles, d.currency, d.validFrom, d.validTo]) + function create(request, batchHash = '') { + const hash = batchHash + JSON.stringify(request.details), prior = commands.get(request.idempotencyKey) + if (prior) return prior.hash === hash ? response(fault === 'mutable creation replay' ? rows.get(prior.row.quoteNo) : prior.row) : response(null, 'A0409') + const d = request.details + if ([d.selfPickupPrice, d.fullDeliveryPrice, d.looseDeliveryPrice].every(v => v == null)) return response(null, 'A0400') + if ([...rows.values()].some(r => natural(r.details) === natural(d))) return response(null, 'A0409') + const row = { quoteNo: `FQ-${++next}`, recordVersion: '0', details: copy(d), partyName: '本轮往来单位', status: 'ACTIVE' } + if (fault === 'null price becomes zero') row.details.selfPickupPrice = 0 + rows.set(row.quoteNo, row); histories.set(row.quoteNo, []); snapshot(row, 'CREATE'); commands.set(request.idempotencyKey, { hash, row: copy(row) }); return response(row) + } + const context = { + marker, context: { fleetQuotesOperationsFixture: { marker, stage: fixtureContract.stage, departureVerified: true, tripNo, warehouseId: '92', departedAt, expected } }, + createdResources: { merchantQualificationFixture: { marker, tenantId: '101' } }, + assert(_module, label, _response, condition) { assert.ok(condition, label) }, + apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data }, + async request(method, path, body) { + calls.push({ method, path, body: copy(body) }); const u = new URL(path, 'http://offline.invalid'), p = u.pathname + if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } }) + if (p === '/api/platform/v1/suppliers' && method === 'POST') { + for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field) + assert.equal(body.truckingScope, 'OVERSEAS'); return response('901') + } + if (p === '/api/platform/v1/suppliers/901/status') { supplierStatus = body.status; return response(null) } + if (p === '/api/platform/v1/suppliers/901') return response({ id: '901', status: supplierStatus }) + if (p === '/api/platform/v1/last-leg/fleet/quotes/import') { + const backup = { rows: copy(rows), histories: copy(histories), commands: copy(commands) }, result = [] + for (let index = 0; index < body.rows.length; index++) { + const outcome = create({ idempotencyKey: `${body.idempotencyKey}:${index + 1}`, details: body.rows[index] }, JSON.stringify(body.rows)) + if (outcome.body.code !== '200') { + if (fault !== 'partial import leak') { rows.clear(); histories.clear(); commands.clear(); backup.rows.forEach((v, k) => rows.set(k, v)); backup.histories.forEach((v, k) => histories.set(k, v)); backup.commands.forEach((v, k) => commands.set(k, v)) } + return outcome + } + result.push(outcome.body.data) + } + return response({ importedRows: result.length, records: result }) + } + if (p === '/api/platform/v1/last-leg/fleet/quotes') { + if (method === 'POST') return create(body) + let records = [...rows.values()] + for (const field of ['kind', 'status']) if (u.searchParams.get(field)) records = records.filter(r => (field === 'kind' ? r.details.kind : r.status) === u.searchParams.get(field)) + const keyword = u.searchParams.get('keyword') || ''; records = records.filter(r => [r.details.originCity, r.details.destinationCode, r.quoteNo].some(v => v.includes(keyword))) + if (fault === 'inactive leaks into active list' && u.searchParams.get('status') === 'ACTIVE') records.push([...rows.values()].find(r => r.status === 'INACTIVE')) + return response({ records, total: String(records.length), page: '1', size: '200', pages: '1' }) + } + const quote = p.match(/\/quotes\/(FQ-\d+)(?:\/(history|status))?$/) + if (quote) { + const row = rows.get(quote[1]); assert.ok(row) + if (method === 'GET') return response(quote[2] === 'history' ? histories.get(row.quoteNo) : row) + if (String(body.recordVersion) !== row.recordVersion) { + if (fault === 'stale write accepted' && method === 'PUT') { row.details = copy(body.details); return response(row) } + return response(null, 'A0409') + } + if (method === 'PUT') row.details = copy(body.details); else row.status = body.status + row.recordVersion = String(Number(row.recordVersion) + 1); snapshot(row, method === 'PUT' ? 'UPDATE' : body.status); return response(row) + } + if (p === '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT/record/history') return response({ records: measuredHistory, total: String(measuredHistory.length), page: '1', size: '20', pages: '1' }) + if (p === '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT/record') { + if (method === 'GET') return response(measured) + const prior = measuredKeys.get(body.idempotencyKey) + if (prior) { if (fault === 'measured replay adds history') measuredHistory.push(copy(measured)); return response(prior) } + if (String(body.recordVersion) !== measured.recordVersion) return response(null, 'A0409') + measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: copy(body.details) } + measuredKeys.set(body.idempotencyKey, copy(measured)); measuredHistory.unshift(copy(measured)); return response(measured) + } + if (p === '/api/platform/v1/last-leg/fleet/operations') { + assert.equal(u.searchParams.get('keyword'), tripNo); assert.equal(u.searchParams.get('warehouseId'), '92') + const metrics = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: '0.00' }] } + const row = { tripNo, ...metrics, measured: measured.details, financeStatus: 'NOT_QUERIED', costs: { totals: [] }, costStatus: fault === 'unknown cost becomes zero' ? 'AVAILABLE' : 'NOT_RECORDED' } + return response({ page: { records: [row], total: '1' }, summary: { trips: fault === 'counts destinations as trips' ? '2' : '1', ...metrics, costs: { visible: true, unrecordedTrips: 1 } }, charts: { physicalPallets: [{ points: [{ value: 1 }] }] } }) + } + throw new Error(`Unexpected offline HTTP: ${method} ${path}`) + }, + async requestBinary(method, path) { + calls.push({ method, path }); const u = new URL(path, 'http://offline.invalid') + const error = response(null, 'A0400') + if (u.pathname.includes('/quotes/') && u.searchParams.get('size') === '0') { const invalid = response(null, 'A0422'); return { ...invalid, contentType: 'application/json', bytes: Buffer.from(JSON.stringify(invalid.body)) } } + if (fault === 'json export masquerades as csv' || (u.searchParams.has('from') && Date.parse(u.searchParams.get('from')) > Date.parse(u.searchParams.get('to')))) return { ...error, contentType: fault ? 'text/csv' : 'application/json', bytes: Buffer.from(JSON.stringify(error.body)) } + const text = u.pathname.includes('/quotes/') ? `\uFEFF报价号,金额\r\n${[...rows.keys()].join('\r\n')}` : `\uFEFF车次号,实际里程(mile)\r\n${tripNo},25.5` + return { http: 200, body: null, bytes: Buffer.from(text), contentType: 'text/csv;charset=UTF-8', requestId: 'offline-request', traceId: 'offline-trace' } + }, + } + return { context, rows, calls, supplierStatus: () => supplierStatus } +} + +test('offline full flow checks quotes, atomic import, current departure and measured history without live network', async () => { + const { context, rows, calls, supplierStatus } = harness(); await run(context); await verifyOperations(context, { writeMeasured: true, idempotencyNamespace: 'OPERATIONS-INDEPENDENT-KEY' }) + const proof = context.createdResources.fleetQuoteAcceptance + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.financePayment, 'NOT_TESTED'); assert.equal(proof.upstreamDeparture, 'NOT_TESTED') + assert.equal(proof.operationsStatus, 'NOT_TESTED'); assert.equal(proof.quoteStatus, 'VERIFIED') + assert.equal(context.createdResources.fleetOperationsAcceptance.operationsStatus, 'VERIFIED'); assert.equal(context.createdResources.fleetOperationsAcceptance.quoteStatus, 'NOT_TESTED') + assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'INACTIVE') + assert.ok(!calls.some(call => call.method === 'DELETE')); assert.ok(!JSON.stringify(proof).includes('password')) +}) +for (const fault of ['null price becomes zero', 'mutable creation replay', 'partial import leak', 'inactive leaks into active list', 'stale write accepted', 'counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'json export masquerades as csv', 'historical trip']) { + test(`verifier fails closed for ${fault}`, async () => { + const { context } = harness(fault) + const operations = ['counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'historical trip'].includes(fault) + await assert.rejects(operations ? verifyOperations(context, { writeMeasured: true, idempotencyNamespace: 'INDEPENDENT-KEY' }) : run(context)) + assert.equal(context.createdResources[operations ? 'fleetOperationsAcceptance' : 'fleetQuoteAcceptance'].status, 'FAILED') + }) +} +test('missing or stale fixture fails before any HTTP and cannot be treated as skipped success', async () => { + for (const alter of [c => delete c.context.fleetQuotesOperationsFixture, c => { c.context.fleetQuotesOperationsFixture.marker = 'OLD' }, c => { c.context.fleetQuotesOperationsFixture.departureVerified = false }]) { + const { context, calls } = harness(); alter(context); await assert.rejects(verifyOperations(context), /current-run/); assert.equal(calls.length, 0); assert.equal(context.createdResources.fleetOperationsAcceptance.status, 'FAILED') + } + const { context, calls } = harness(); context.createdResources.merchantQualificationFixture.marker = 'OLD' + await assert.rejects(run(context), /current-run/); assert.equal(calls.length, 0) +}) +test('proof cannot be overwritten on rerun and CSV detector rejects misleading success content', async () => { + const { context } = harness(); await run(context); const proof = context.createdResources.fleetQuoteAcceptance + await assert.rejects(run(context), /Existing proof/); assert.equal(context.createdResources.fleetQuoteAcceptance, proof) + assert.throws(() => readCsv({ http: 200, contentType: 'text/csv', bytes: Buffer.from('{"code":"A0403"}') }, '报价号'), /not a valid CSV/) + assert.throws(() => requireCurrentFixture({ marker: 'x' }), /current-run/) +}) +test('unknown expected money is rejected before HTTP instead of comparing as zero', async () => { + const { context, calls } = harness(); context.context.fleetQuotesOperationsFixture.expected.externalAgreedAmounts[0].amount = null + await assert.rejects(verifyOperations(context), /never convert unknown to zero/); assert.equal(calls.length, 0) +}) +test('quote acceptance is independent of DEPART and never requests operations, inventory or finance', async () => { + const { context, calls } = harness(); delete context.context.fleetQuotesOperationsFixture + await run(context) + assert.equal(context.createdResources.fleetQuoteAcceptance.scope, 'QUOTES_ONLY') + assert.equal(context.createdResources.fleetQuoteAcceptance.operationsStatus, 'NOT_TESTED') + assert.ok(calls.every(call => !/operations|inventory|finance|\/trips\//.test(call.path))) +}) +test('operations defaults to read-only and never creates customer, supplier or quote records', async () => { + const { context, calls, rows } = harness(); delete context.createdResources.merchantQualificationFixture + await verifyOperations(context) + assert.ok(calls.every(call => call.method === 'GET')); assert.equal(rows.size, 0) + assert.equal(context.createdResources.fleetOperationsAcceptance.measuredStatus, 'NOT_TESTED') +}) +test('measured writes require an explicit independent idempotency namespace', async () => { + const { context, calls } = harness(); await assert.rejects(verifyOperations(context, { writeMeasured: true }), /independent idempotency/) + assert.equal(calls.length, 0) +}) +test('independent quote session logs out after a failed quote scenario and persists scoped failure without secrets', async () => { + const offline = harness('null price becomes zero') + const context = new AcceptanceRun({ environment: 'test', gateway: 'http://offline.invalid', username: 'test-user', password: 'synthetic-password', quiet: true }) + const authCalls = [] + context.request = async (method, path, body) => { + if (path.startsWith('/api/auth/')) { + authCalls.push(path) + if (path.endsWith('/logout')) { assert.equal(body.refreshToken, 'synthetic-refresh'); return { http: 200, body: { code: '200', data: null } } } + return { http: 200, body: { code: '200', data: { accessToken: 'synthetic-access', refreshToken: 'synthetic-refresh' } } } + } + return offline.context.request(method, path, body) + } + context.requestBinary = offline.context.requestBinary + const report = await runQuoteSession(context, { marker: 'FLEET-QUOTE-20261003', prepareCustomer: async (current, options) => { + assert.equal(options.publishEnvironment, false) + current.createdResources.merchantQualificationFixture = { marker: current.marker, tenantId: '101' } + } }) + assert.deepEqual(authCalls, ['/api/auth/login', '/api/auth/logout']) + assert.equal(report.success, false); assert.equal(report.loggedOut, true); assert.equal(report.operationsStatus, 'NOT_TESTED') + assert.equal(report.createdResources.fleetQuoteAcceptance.status, 'FAILED') + assert.equal(context.token, ''); assert.equal(context.refreshToken, '') + assert.ok(!/synthetic-password|synthetic-access|synthetic-refresh/.test(JSON.stringify(report))) +}) +test('session helper rejects a borrowed login before touching HTTP', async () => { + const context = new AcceptanceRun({ environment: 'test', quiet: true }); context.token = 'already-logged-in' + context.request = () => { throw new Error('must not run') } + await assert.rejects(runQuoteSession(context), /fresh TEST context/) +}) diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs new file mode 100644 index 000000000..f710c0ee3 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto' + +const MODULE = 'fleet-quotes-operations' +const QUOTES = '/api/platform/v1/last-leg/fleet/quotes' +const OPERATIONS = '/api/platform/v1/last-leg/fleet/operations' +const OPTIONS = { scenario: 'fleet-quotes-and-current-departure', deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const successful = response => response?.http === 200 && id(response.body?.code) === '200' +const encode = encodeURIComponent +const query = values => new URLSearchParams(Object.entries(values).filter(([, value]) => value != null).map(([key, value]) => [key, String(value)])).toString() +const version = value => BigInt(id(value)) +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +function number(value) { + if (!['number', 'string'].includes(typeof value) || !/^\d+(?:\.\d{1,2})?$/.test(String(value)) || !Number.isFinite(Number(value))) throw new Error('Missing or invalid numeric fact; never convert unknown to zero') + return Number(value) +} + +export const fixtureContract = Object.freeze({ + quoteEntry: 'run(context): QUOTES_ONLY; requires only current-run merchant-qualification-fixture, never DEPART', + operationsEntry: 'verifyOperations(context,{writeMeasured:false,idempotencyNamespace}): OPERATIONS_ONLY, read-only by default; explicit writeMeasured:true uses a separate stable idempotency namespace', + contextKey: 'context.fleetQuotesOperationsFixture', + stage: 'CURRENT_RUN_DEPARTED', + required: 'marker,tripNo,warehouseId,departedAt,departureVerified:true,expected:{physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts:[{currency,amount}],costs:[{currency,directCost,ownCost,externalCost}]}', + prerequisite: 'Operations require a current-run real HTTP departure producer and a trip remark containing its marker. Quotes require their own current-run qualified customer only. These are independent acceptance scopes.', + boundary: 'No historical fallback, no invented DEPART, no SQL or deployment. Does not prove the upstream departure transaction or Finance payment. Import exercises the structured rows produced by the CSV UI, not browser file selection.', + effects: 'Creates a fresh overseas TRUCKING supplier and isolated purchase/sale quotes; appends measured distance/fuel corrections to the supplied current-run trip. Finally deactivates only own quotes/supplier; retains all rows/history. No DELETE.', +}) + +export function requireCurrentFixture(context) { + const fixture = context?.context?.fleetQuotesOperationsFixture + if (!context?.marker || !fixture || fixture.marker !== context.marker || fixture.stage !== fixtureContract.stage + || fixture.departureVerified !== true || !fixture.tripNo || !fixture.warehouseId || !Number.isFinite(Date.parse(fixture.departedAt))) throw new Error('Requires current-run verified real DEPART fixture; historical samples are forbidden') + const expected = fixture.expected + if (!expected || ['physicalPallets', 'palletSpaces', 'ownPackages', 'externalPackages'].some(key => !Number.isSafeInteger(expected[key]) || expected[key] < 0) + || expected.physicalPallets < 1 || expected.palletSpaces < 1 || expected.ownPackages + expected.externalPackages < 1 + || !Array.isArray(expected.externalAgreedAmounts) || !Array.isArray(expected.costs)) throw new Error('Requires independent expected cargo and currency-separated cost facts') + amounts(expected.externalAgreedAmounts); costs(expected.costs) + return fixture +} +function requireQuoteCustomer(context) { + const customer = context?.createdResources?.merchantQualificationFixture + if (!context?.marker || customer?.marker !== context.marker || !customer.tenantId) throw new Error('Requires current-run qualified customer for quote acceptance') + return customer +} + +export function readCsv(response, requiredHeader) { + const text = new TextDecoder().decode(response?.bytes || new Uint8Array()).replace(/^\uFEFF/, '') + if (response?.http !== 200 || /json/i.test(response?.contentType || '') || /^\s*[{[]/.test(text) + || !/^text\/csv(?:;|$)/i.test(response?.contentType || '') || !text.split(/\r?\n/, 1)[0].includes(requiredHeader)) { + throw new Error(`Export is not a valid CSV: HTTP=${response?.http}, code=${response?.body?.code || 'n/a'}, requestId=${response?.requestId || 'n/a'}, traceId=${response?.traceId || 'n/a'}`) + } + return text +} +const currency = value => { if (!['CNY', 'USD'].includes(value)) throw new Error('Unsupported or missing currency fact'); return value } +const amounts = rows => rows.map(row => [currency(row.currency), number(row.amount)]).sort((a, b) => a[0].localeCompare(b[0])) +const costs = rows => rows.map(row => [currency(row.currency), number(row.directCost), row.ownCost == null ? null : number(row.ownCost), row.externalCost == null ? null : number(row.externalCost)]).sort((a, b) => a[0].localeCompare(b[0])) + +/** Only the registered TEST runner invokes run; importing this module never reads credentials or calls HTTP. */ +export async function run(context) { return execute(context, { scope: 'QUOTES_ONLY' }) } +export async function verifyOperations(context, { writeMeasured = false, idempotencyNamespace } = {}) { + if (writeMeasured && !idempotencyNamespace) throw new Error('Measured corrections require an explicit independent idempotency namespace') + return execute(context, { scope: 'OPERATIONS_ONLY', writeMeasured, idempotencyNamespace }) +} +/** Explicit orchestrator helper. Caller supplies a fresh AcceptanceRun with TEST options; no global env mutation. + * Real standalone execution uses fleet-quotes-test-env.mjs, which journals every request before sending. + * This return value is a final summary, not a replacement for durable pre-request evidence. + */ +export async function runQuoteSession(context, { marker = 'FLEET-QUOTE-20261003', prepareCustomer } = {}) { + if (context?.options?.environment !== 'test' || context.token || context.refreshToken || Object.keys(context.createdResources || {}).length) + throw new Error('Quote session requires a fresh TEST context, never a borrowed session or fixture') + context.marker = marker + context.options.customerName = `报价专用客户-${marker}` + let fatal = null, loggedOut = false + try { + const response = await context.request('POST', '/api/auth/login', { loginId: context.options.username, password: context.options.password }, '') + const login = context.apiSuccess(MODULE, '独立报价验收会话登录', response, '', OPTIONS) + if (!successful(response) || !login?.accessToken || !login?.refreshToken) throw new Error('Independent TEST login failed') + context.token = login.accessToken; context.refreshToken = login.refreshToken + const prepare = prepareCustomer || (await import('./merchant-qualification-fixture.mjs')).run + await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false })) + if (context.steps.some(step => !step.passed)) throw new Error('Current-run customer preparation failed; quotes not executed') + await context.executeModule(MODULE, () => run(context)) + } catch (error) { fatal = error instanceof Error ? error.message : String(error) } + finally { + if (context.token) { + const response = await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken }) + context.apiSuccess(MODULE, '退出独立报价验收会话', response, '', OPTIONS) + loggedOut = successful(response) + if (!loggedOut) fatal ||= 'Independent TEST session logout failed' + } + } + const { buildAcceptanceSummary } = await import('../admin-test-env-acceptance.mjs') + const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) + const report = { marker, scope: 'QUOTES_ONLY', generatedAt: new Date().toISOString(), success: !fatal && loggedOut && summary.failed === 0, + fatal, loggedOut, operationsStatus: 'NOT_TESTED', financeStatus: 'NOT_TESTED', summary, + modules: context.modules, steps: context.steps, createdResources: context.createdResources } + const serialized = JSON.stringify(report) + const secrets = [context.options.password, context.token, context.refreshToken, context.context?.merchantQualificationFixture?.password].filter(value => typeof value === 'string' && value.length >= 8) + if (secrets.some(value => serialized.includes(value)) || /"(?:password|accessToken|refreshToken|authorization)"\s*:/i.test(serialized)) throw new Error('Unsafe report rejected: contains credentials') + context.token = ''; context.refreshToken = '' + return report +} +async function execute(context, { scope, writeMeasured = false, idempotencyNamespace }) { + context.createdResources ||= {} + const proofKey = scope === 'QUOTES_ONLY' ? 'fleetQuoteAcceptance' : 'fleetOperationsAcceptance' + if (context.createdResources[proofKey]) throw new Error('Existing proof must be retained; do not overwrite failed evidence') + const proof = context.createdResources[proofKey] = { marker: context.marker, scope, status: 'CHECKING', quoteStatus: 'NOT_TESTED', operationsStatus: 'NOT_TESTED', measuredStatus: 'NOT_TESTED', requests: [], quotes: [], supplierId: null, cleanup: 'PENDING', upstreamDeparture: 'NOT_TESTED', financePayment: 'NOT_TESTED' } + let failure + const check = (label, response, condition) => { + context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(label) + } + const key = suffix => createHash('sha256').update(`${idempotencyNamespace || context.marker}:${suffix}`).digest('hex') + const recordResponse = (method, path, response) => proof.requests.push({ method, path, http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) + async function request(method, path, body) { const response = await context.request(method, path, body); recordResponse(method, path, response); return response } + async function command(method, path, body, label) { + const response = await request(method, path, body) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + check(`${label}保留链路证据`, response, successful(response) && response.requestId && response.traceId && data != null) + return data + } + async function reject(method, path, body, expectedCode, label) { + const response = await request(method, path, body) + check(label, response, response.http === 200 && id(response.body?.code) === expectedCode && response.requestId && response.traceId) + } + async function binary(path) { const response = await context.requestBinary('GET', path); recordResponse('GET', path, response); return response } + function retain(row) { + check('报价返回稳定业务号', null, typeof row?.quoteNo === 'string' && row.quoteNo.length > 0) + if (!proof.quotes.includes(row.quoteNo)) proof.quotes.push(row.quoteNo) + return row + } + try { + let fixture, from, to, report + if (scope === 'OPERATIONS_ONLY') { + fixture = requireCurrentFixture(context) + proof.tripNo = fixture.tripNo + const trip = await command('GET', `/api/platform/v1/last-leg/fleet/trips/${encode(fixture.tripNo)}`, undefined, '核对本轮真实发车车次') + check('车次属于当前场景并有真实发车时间', null, trip.trip?.tripNo === fixture.tripNo && id(trip.trip.warehouseId) === id(fixture.warehouseId) + && trip.trip.remark?.includes(context.marker) && Date.parse(trip.trip.departedAt) === Date.parse(fixture.departedAt) + && ['IN_TRANSIT', 'COMPLETED'].includes(trip.trip.status)) + from = new Date(Date.parse(fixture.departedAt) - 1).toISOString(); to = new Date(Date.parse(fixture.departedAt) + 1000).toISOString() + const reportPath = `${OPERATIONS}?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo, page: 1, size: 20 })}` + report = async () => { + const result = await command('GET', reportPath, undefined, '读取本轮车次运营与成本汇总') + check('同车多客户及目的地只计一次出车', null, Number(result.page?.total) === 1 && result.page.records?.length === 1 && Number(result.summary?.trips) === 1 && result.page.records[0].tripNo === fixture.tripNo) + const row = result.page.records[0] + for (const field of ['physicalPallets', 'palletSpaces', 'ownPackages', 'externalPackages']) check(`实运${field}与独立fixture一致`, null, number(row[field]) === fixture.expected[field] && number(result.summary[field]) === fixture.expected[field]) + check('约定金额分币种核平且不伪造实收', null, same(amounts(row.externalAgreedAmounts), amounts(fixture.expected.externalAgreedAmounts)) && same(amounts(result.summary.externalAgreedAmounts), amounts(fixture.expected.externalAgreedAmounts)) && row.financeStatus === 'NOT_QUERIED') + check('成本使用正式批量事实而非缺失补零', null, result.summary.costs?.visible === true && row.costs && same(costs(row.costs.totals), costs(fixture.expected.costs)) + && row.costStatus === (fixture.expected.costs.length ? 'AVAILABLE' : 'NOT_RECORDED') && Number(result.summary.costs.unrecordedTrips) === (fixture.expected.costs.length ? 0 : 1)) + check('统计图来自同一查询范围', null, result.charts?.physicalPallets?.flatMap(series => series.points).reduce((sum, point) => sum + Number(point.value), 0) === fixture.expected.physicalPallets) + return result + } + await report() + } + if (scope === 'QUOTES_ONLY') { + const customer = requireQuoteCustomer(context) + const supplier = await command('POST', '/api/platform/v1/suppliers?tenantId=0', { + name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker, + region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30', + }, '新建本轮海外车队供应商') + proof.supplierId = id(supplier.id ?? supplier) + check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId)) + const day = new Date().toISOString().slice(0, 10) + const details = { kind: 'SALE', partyId: id(customer.tenantId), originCity: `QA-${context.marker}`, destinationCode: 'QA-FLEET', distanceMiles: 10, + currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: 0, looseDeliveryPrice: 12.5, validFrom: day, validTo: day, remark: context.marker } + const create = { idempotencyKey: key('sale'), details } + await reject('POST', QUOTES, { idempotencyKey: key('no-price'), details: { ...details, fullDeliveryPrice: null, looseDeliveryPrice: null } }, 'A0400', '所有价格为空不能成为免费报价') + const sale = retain(await command('POST', QUOTES, create, '创建空价与明确零价销售报价')) + check('空价与零价严格区分', null, sale.details?.selfPickupPrice == null && sale.details?.fullDeliveryPrice != null && Number(sale.details.fullDeliveryPrice) === 0 && Number(sale.details.looseDeliveryPrice) === 12.5 && id(sale.details.partyId) === id(customer.tenantId)) + const replay = await command('POST', QUOTES, create, '重放原报价创建请求') + check('创建重放不新增报价或版本', null, replay.quoteNo === sale.quoteNo && id(replay.recordVersion) === id(sale.recordVersion)) + await reject('POST', QUOTES, { ...create, details: { ...details, looseDeliveryPrice: 13 } }, 'A0409', '同键异价拒绝') + let current = await command('PUT', `${QUOTES}/${encode(sale.quoteNo)}`, { recordVersion: sale.recordVersion, details: { ...details, looseDeliveryPrice: 15 } }, '更正销售报价') + check('更正只递增一个版本', null, version(current.recordVersion) === version(sale.recordVersion) + 1n) + await reject('PUT', `${QUOTES}/${encode(sale.quoteNo)}`, { recordVersion: sale.recordVersion, details }, 'A0409', '旧版本不能覆盖新报价') + const afterConflict = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}`, undefined, '回查版本冲突无写入') + check('冲突后价格版本不漂移且空价零价已持久化', null, id(afterConflict.recordVersion) === id(current.recordVersion) && number(afterConflict.details.looseDeliveryPrice) === 15 + && afterConflict.details.selfPickupPrice == null && afterConflict.details.fullDeliveryPrice != null && number(afterConflict.details.fullDeliveryPrice) === 0) + const historicalReplay = await command('POST', QUOTES, create, '编辑后原创建键仍返回原始快照') + check('创建重放不会返回被更正价格冒充原结果', null, id(historicalReplay.recordVersion) === id(sale.recordVersion) && Number(historicalReplay.details.looseDeliveryPrice) === 12.5) + const purchase = retain(await command('POST', QUOTES, { idempotencyKey: key('purchase'), details: { ...details, kind: 'PURCHASE', partyId: proof.supplierId } }, '创建独立采购报价')) + check('采购销售没有合并成同一记录', null, purchase.quoteNo !== sale.quoteNo && purchase.details.kind === 'PURCHASE' && id(purchase.details.partyId) === proof.supplierId) + const importCity = `IMPORT-${context.marker}` + const invalidBatch = { idempotencyKey: key('rollback-import'), rows: [{ ...details, originCity: importCity }, { ...details, originCity: importCity }] } + await reject('POST', `${QUOTES}/import`, invalidBatch, 'A0409', '重复组合导致整批导入失败') + const rollback = await command('GET', `${QUOTES}?${query({ keyword: importCity, kind: 'SALE', page: 1, size: 200 })}`, undefined, '回查失败批次无部分写入') + proof.rollbackReturnedQuoteNos = rollback.records.map(row => row.quoteNo) + check('CSV结构化导入失败整批回滚', null, Number(rollback.total) === 0 && rollback.records.length === 0) + const batch = { idempotencyKey: key('valid-import'), rows: [{ ...details, originCity: importCity }, { ...details, originCity: importCity, destinationCode: 'QA-FLEET-B' }] } + const imported = await command('POST', `${QUOTES}/import`, batch, '原子导入两条报价') + check('成功导入返回两条业务记录', null, Number(imported.importedRows) === 2 && imported.records?.length === 2) + imported.records.forEach(retain) + const importedAgain = await command('POST', `${QUOTES}/import`, batch, '重放原批次导入') + check('整批重放保持原业务号和版本', null, same(importedAgain.records.map(r => [r.quoteNo, id(r.recordVersion)]), imported.records.map(r => [r.quoteNo, id(r.recordVersion)]))) + await reject('POST', `${QUOTES}/import`, { ...batch, rows: batch.rows.slice(0, 1) }, 'A0409', '同批次键不能缩短或改变内容') + const confirmedImport = await command('GET', `${QUOTES}?${query({ keyword: importCity, kind: 'SALE', page: 1, size: 200 })}`, undefined, '核对导入重放无重复行') + check('导入重放及拒绝后仍恰好两条', null, Number(confirmedImport.total) === 2 && new Set(confirmedImport.records.map(r => r.quoteNo)).size === 2) + current = await command('PATCH', `${QUOTES}/${encode(sale.quoteNo)}/status`, { recordVersion: current.recordVersion, status: 'INACTIVE' }, '停用销售报价') + const activeSales = await command('GET', `${QUOTES}?${query({ keyword: details.originCity, kind: 'SALE', status: 'ACTIVE', page: 1, size: 200 })}`, undefined, '核对停用及用途筛选隔离') + check('停用销售不被启用筛选或采购混入', null, activeSales.records.every(r => r.details.kind === 'SALE' && r.status === 'ACTIVE' && r.quoteNo !== sale.quoteNo)) + const stillPurchase = await command('GET', `${QUOTES}/${encode(purchase.quoteNo)}`, undefined, '核对采购报价不受销售停用影响') + check('用途独立状态未串改', null, stillPurchase.status === 'ACTIVE' && id(stillPurchase.recordVersion) === id(purchase.recordVersion)) + const history = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}/history`, undefined, '读取报价不可变版本历史') + check('历史保留原始零价与更正版本', null, history.length === 3 && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 12.5) && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 15)) + const quoteCsv = await binary(`${QUOTES}/export?${query({ keyword: details.originCity })}`) + const quoteText = readCsv(quoteCsv, '报价号') + check('报价CSV为真实文件并包含本轮业务号', quoteCsv, quoteText.includes(sale.quoteNo) && quoteText.includes(purchase.quoteNo)) + const error = await binary(`${QUOTES}/export?size=0`) + check('报价导出参数错误保留业务码', error, error.http === 200 && id(error.body?.code) === 'A0422') + let rejected = false; try { readCsv(error, '报价号') } catch { rejected = true } + check('报价下载拒绝JSON错误文件', error, rejected) + proof.quoteStatus = 'VERIFIED' + } + if (scope === 'OPERATIONS_ONLY') { + if (writeMeasured) { + const recordPath = `${OPERATIONS}/${encode(fixture.tripNo)}/record` + const initial = await command('GET', recordPath, undefined, '核对本轮车次尚无实测记录') + check('禁止覆盖已有实测历史样本', null, id(initial.recordVersion) === '0' && initial.details.actualDistanceMiles == null && initial.details.actualFuelLitres == null) + const measured = { recordVersion: initial.recordVersion, idempotencyKey: key('measured-zero'), details: { actualDistanceMiles: 0, actualFuelLitres: null, remark: context.marker }, reason: null } + const first = await command('PUT', recordPath, measured, '登记明确零里程和未知油耗') + check('实测明确零值不变成未填写', null, first.details.actualDistanceMiles != null && Number(first.details.actualDistanceMiles) === 0 && first.details.actualFuelLitres == null) + const firstAgain = await command('PUT', recordPath, measured, '重放实测保存') + check('实测重放保留原版本', null, id(firstAgain.recordVersion) === id(first.recordVersion)) + const corrected = await command('PUT', recordPath, { recordVersion: first.recordVersion, idempotencyKey: key('measured-correct'), details: { actualDistanceMiles: 25.5, actualFuelLitres: 8.2, remark: context.marker }, reason: '核对受控实测' }, '更正实测里程油耗') + await reject('PUT', recordPath, { ...measured, idempotencyKey: key('measured-stale') }, 'A0409', '旧实测版本拒绝') + const measuredHistory = await command('GET', `${recordPath}/history?page=1&size=20`, undefined, '读取实测更正历史') + check('实测重放与冲突没有追加历史', null, Number(measuredHistory.total) === 2 && version(corrected.recordVersion) === version(first.recordVersion) + 1n) + proof.measuredStatus = 'VERIFIED' + } + const finalReport = await report() + if (writeMeasured) check('运营列表批量回显最新实测记录', null, Number(finalReport.page.records[0].measured?.actualDistanceMiles) === 25.5 && Number(finalReport.page.records[0].measured?.actualFuelLitres) === 8.2) + const exported = await binary(`${OPERATIONS}/export?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo })}`) + const operationsCsv = readCsv(exported, '实际里程(mile)') + check('运营CSV包含本轮车次及实际里程字段', exported, operationsCsv.includes(fixture.tripNo) && (!writeMeasured || operationsCsv.includes('25.5'))) + const exportError = await binary(`${OPERATIONS}/export?${query({ from: to, to: from })}`) + check('错误导出保留标准业务错误而非CSV成功', exportError, exportError.http === 200 && id(exportError.body?.code) === 'A0400') + let rejected = false + try { readCsv(exportError, '车次号') } catch { rejected = true } + check('下载检查器拒绝JSON错误响应', exportError, rejected) + proof.operationsStatus = 'VERIFIED' + } + proof.status = 'VERIFIED' + } catch (error) { proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error); failure = error } + finally { + const cleanupErrors = [] + for (const no of context.quoteRequestOutcomeUnknown ? [] : proof.quotes) { + try { + const row = await command('GET', `${QUOTES}/${encode(no)}`, undefined, '读取本轮报价最终版本') + if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史') + } catch (error) { cleanupErrors.push(error.message) } + } + if (proof.supplierId && !context.quoteRequestOutcomeUnknown) { + try { + const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}` + const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' }) + context.apiSuccess(MODULE, '停用本轮车队供应商并保留证据', response, '', OPTIONS) + check('供应商停用命令成功', response, successful(response)) + const supplier = await command('GET', `${path}?tenantId=0`, undefined, '回查本轮车队供应商已停用') + check('本轮车队供应商最终停用', null, supplier.status === 'INACTIVE' && id(supplier.id) === proof.supplierId) + } catch (error) { cleanupErrors.push(error.message) } + } + proof.cleanup = context.quoteRequestOutcomeUnknown ? 'NOT_ATTEMPTED_UNKNOWN_OUTCOME' : cleanupErrors.length ? 'FAILED' : proof.quotes.length || proof.supplierId ? 'RETAINED_AND_DEACTIVATED' : 'NOTHING_CREATED' + if (cleanupErrors.length) { proof.cleanupErrors = cleanupErrors; proof.status = 'FAILED'; failure ||= new Error('本轮报价资源停用失败') } + } + if (failure) throw failure +} diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs new file mode 100644 index 000000000..911d10464 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } from './fleet-trip-cost-acceptance.mjs' + +const copy = value => value === undefined ? undefined : structuredClone(value) +const prefix = 'platform:fleet:trip:' +function fixture(fault) { + const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map() + const shares = [ + { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] }, + { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] }, + ] + const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' } + const depart = { recordVersion: 7, action: 'DEPART', snapshot: { + trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z' }, + confirmedLoad: { manifestHash: 'original-manifest', pallets: [ + { warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }], + cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5 }, + { warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9 }] }, + externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND', + receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } } + if (fault === 'unbound external') depart.snapshot.externalBookings[0].status = 'CONFIRMED' + if (fault === 'wrong stop') depart.snapshot.confirmedLoad.pallets[1].deliveryIndex = 2 + if (fault === 'wrong receipt') depart.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 8 + if (fault === 'wrong weights') shares[0].palletSpaces = 3 + function summary() { + return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest', + allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationBlockReason: null, + totals: ['CNY', 'USD'].filter(currency => rows.some(row => row.details.currency === currency)).map(currency => { + const amount = rows.filter(row => row.details.currency === currency).reduce((sum, row) => sum + Math.round(Number(row.details.amount) * 100), 0) + // Hard-coded independently calculated expected cent splits for 2:1 physical spaces. + const own = currency === 'USD' ? 871 : amount === 10003 ? 6669 : 6667 + const external = currency === 'USD' ? 436 : 3334 + return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [ + { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 }, + { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL', + palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] } + }) } + } + const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'request', traceId: 'trace' }) + const listing = () => ({ page: { records: copy(rows), total: rows.length, page: 1, size: 100 }, summary: summary() }) + function audit(row, action) { + const history = histories.get(row.costNo) || [] + history.push({ recordVersion: row.recordVersion, action, reason: 'controlled audit', occurredAt: new Date().toISOString(), + snapshot: { cost: copy(row), summary: summary() } }) + histories.set(row.costNo, history) + } + const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip], + fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo, + departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } }, + assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {}, + async request(method, path, body, token) { + calls.push({ method, path, body: copy(body), actor: token }) + if (path.endsWith('/authorization/permissions')) return response((token === 'offline-writer' || fault === 'reader is admin' + ? ['list', 'cost-create', 'cost-edit'] : token === 'offline-reader' ? ['list'] : []).map(name => prefix + name)) + const denied = token === 'offline-denied' || (token === 'offline-reader' && method !== 'GET') + if (denied && fault !== 'permission bypass') return fault === 'denial is outage' ? response(null, 'S0503', 503) : response(null, 'A0403', 403) + if (method === 'GET' && path.includes('/history') && !path.includes('/costs')) return response({ records: [depart], total: 1 }) + if (method === 'GET' && path.includes('/history')) { + const costNo = path.split('/costs/')[1].split('/')[0], history = copy(histories.get(costNo)) + if (fault === 'history overwritten') history[0].snapshot.cost.details.amount = '100.03' + if (fault === 'history summary drift') history[0].snapshot.summary.totals[0].directCost = 999 + return response({ records: history, total: history.length }) + } + if (method === 'GET') return response(listing()) + if (method === 'POST') { + const saved = keys.get(body.idempotencyKey) + if (saved) { + if (JSON.stringify(saved.details) !== JSON.stringify(body.details)) return response(null, 'A0409') + if (fault === 'replay duplicate') { const duplicate = { ...copy(saved.row), costNo: `TC-${rows.length + 1}` }; rows.push(duplicate); return response(duplicate) } + if (fault === 'creation overwrites correction') saved.row.details = copy(body.details) + return response(saved.row) + } + const row = { costNo: `TC-${rows.length + 1}`, tripNo: trip.tripNo, recordVersion: 0, details: copy(body.details) } + rows.push(row); keys.set(body.idempotencyKey, { details: copy(body.details), row }); audit(row, 'CREATE'); return response(row) + } + assert.equal(method, 'PUT') + const row = rows.find(item => path.endsWith(item.costNo)); assert.ok(row) + if (Number(body.recordVersion) !== row.recordVersion && fault !== 'stale accepted') return response(null, 'A0409') + if (JSON.stringify(row.details) === JSON.stringify(body.details) && fault !== 'noop audit') return response(row) + row.details = copy(body.details); row.recordVersion++; audit(row, 'UPDATE'); return response(row) + } } + return { context, trip, inputs, calls, rows } +} + +test('physical DEPART costs include original currencies, correction, immutable history and three permission actors', async () => { + const f = fixture(), proof = await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.expected.finalCny, '100.03') + assert.equal(proof.final.summary.totals.find(row => row.currency === 'CNY').ownCost, 66.69) + assert.deepEqual(proof.coverage.sourceTypes, ['OWN', 'EXTERNAL']) + assert.ok(f.calls.every(call => call.path.includes('/fleet/trips/FT-CURRENT') || call.path.endsWith('/authorization/permissions'))) + assert.ok(!JSON.stringify(proof).includes('offline-reader')); assert.ok(!JSON.stringify(proof).includes('offline-denied')) + assert.ok(proof.requests.filter(row => row.method !== 'GET').every(row => row.body)) + const count = f.calls.length + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /重复开始/) + assert.equal(f.calls.length, count) +}) + +test('cost expectation uses stable largest remainder and conserves every cent including tie and zero', () => { + const shares = ['B', 'A', 'C'].map(businessNo => ({ businessNo, sourceType: 'OWN', dispatchNo: businessNo, palletSpaces: 1 })) + assert.deepEqual(expectedAllocations('0.02', shares).map(row => [row.businessNo, row.cents]), [['A', '1'], ['B', '1'], ['C', '0']]) + assert.deepEqual(expectedAllocations('0', shares).map(row => row.cents), ['0', '0', '0']) + assert.equal(TRIP_COST_EXPECTATIONS.finalCny, '100.03') +}) + +for (const fault of ['unbound external', 'wrong stop', 'wrong receipt', 'wrong weights', 'wrong departure', 'external counted own', + 'lost cent', 'reader is admin', 'denial is outage', 'permission bypass', 'history overwritten', 'history summary drift', + 'replay duplicate', 'creation overwrites correction', 'stale accepted', 'noop audit']) { + test(`rejects ${fault} and preserves evidence without cleanup`, async () => { + const f = fixture(fault) + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)) + assert.equal(f.trip.costAcceptance.status, 'FAILED') + assert.ok(f.calls.every(call => call.method !== 'DELETE')) + }) +} + +test('missing current-run membership or permission session fails before HTTP', async () => { + for (const mutate of [f => { f.inputs.deniedToken = '' }, f => { f.context.createdResources.fleetTrips = [] }, + f => { f.context.createdResources.fleetAcceptance.marker = 'OLD' }, f => { f.inputs.shares = [] }]) { + const f = fixture(); mutate(f) + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)); assert.equal(f.calls.length, 0) + } +}) + +test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => { + const f = fixture(), original = f.context.request + f.context.request = async (...args) => { + if (args[0] === 'POST' && args[3] === f.context.token) throw new Error('controlled network loss') + return original(...args) + } + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /network loss/) + const entry = f.trip.costAcceptance.requests.at(-1) + assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.ok(entry.body.idempotencyKey) + assert.equal(f.rows.length, 0) +}) diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs new file mode 100644 index 000000000..2af2ed5b6 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs @@ -0,0 +1,185 @@ +import { randomUUID } from 'node:crypto' + +const OPTIONS = { scenario: 'fleet-trip-operating-cost', deploymentBlocker: true } +const PERM = 'platform:fleet:trip:' +const AUTH = '/api/platform/v1/iam/authorization/permissions' +const clone = value => value === undefined ? undefined : structuredClone(value) +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const sorted = rows => [...rows].sort((a, b) => a.businessNo < b.businessNo ? -1 : a.businessNo > b.businessNo ? 1 : 0) +const cents = value => { + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value)) + if (!match) throw new Error('成本金额不是非负两位小数') + return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0')) +} +export const TRIP_COST_EXPECTATIONS = Object.freeze({ initialCny: '100.01', finalCny: '100.03', usd: '13.07', rows: 2, + history: { CNY: ['CREATE', 'UPDATE'], USD: ['CREATE'] }, allocationMethod: 'SAME_ROUTE_PALLET_SPACES' }) + +/** Integer-cent oracle for the documented largest-remainder rule; does not consume API allocations. */ +export function expectedAllocations(amount, shares) { + const total = shares.reduce((sum, row) => sum + BigInt(row.palletSpaces), 0n) + const rows = shares.map(row => ({ ...row, value: cents(amount) * BigInt(row.palletSpaces) / total, + remainder: cents(amount) * BigInt(row.palletSpaces) % total })) + rows.sort((a, b) => a.remainder > b.remainder ? -1 : a.remainder < b.remainder ? 1 : a.businessNo < b.businessNo ? -1 : 1) + let rest = cents(amount) - rows.reduce((sum, row) => sum + row.value, 0n) + for (const row of rows) if (rest > 0n) { row.value++; rest-- } + return sorted(rows).map(row => ({ businessNo: row.businessNo, sourceType: row.sourceType, dispatchNo: row.dispatchNo, + palletSpaces: row.palletSpaces, cents: String(row.value) })) +} + +/** + * C calls after real DEPART + replay, before releasing the fixture. No entry registration or IAM mutation. + * tripProof must be the object in createdResources.fleetTrips and fleetAcceptance.departure must match. + * inputs.shares is the caller's independently known OWN dispatch / EXTERNAL booking mapping: + * {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (same unloading stop). + * inputs.listOnlyToken/deniedToken are current-run platform sessions owned and cleaned up by the caller. + * Neither token is persisted. Missing actors/facts fail before cost writes. No automatic restart/cleanup. + */ +export async function verifyFleetTripCosts(context, module, tripProof, inputs = {}) { + const run = context.createdResources?.fleetAcceptance, shares = inputs.shares + if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker + || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || run.departure?.tripNo !== tripProof.tripNo + || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt || tripProof.costAcceptance + || !inputs.listOnlyToken || !inputs.deniedToken || inputs.listOnlyToken === inputs.deniedToken + || !Array.isArray(shares) || !shares.length || new Set(shares.map(row => row.businessNo)).size !== shares.length + || shares.some(row => !row.businessNo || !row.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType) + || !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1 || !Array.isArray(row.warehousePalletIds) + || !row.warehousePalletIds.length || row.warehousePalletIds.some(id => !id))) + throw new Error('车次成本验收须提供本轮唯一真实DEPART车次、独立托位预期及两类权限会话;禁止借旧样本或重复开始') + const proof = tripProof.costAcceptance = { marker: context.marker, status: 'CHECKING', tripNo: tripProof.tripNo, + expected: clone(TRIP_COST_EXPECTATIONS), shares: clone(shares), requests: [], costs: [] } + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${base}/costs` + const check = (label, condition, response) => { + context.assert(module, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(`${module}: ${label}`) + } + async function request(method, url, body, label, actor = 'writer', rejectCode) { + const entry = { method, path: url, body: clone(body), label, actor, status: 'SENDING' } + proof.requests.push(entry); await context.checkpoint?.() + try { + const token = actor === 'reader' ? inputs.listOnlyToken : actor === 'denied' ? inputs.deniedToken : context.token + const response = await context.request(method, url, body, token) + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + await context.checkpoint?.() + const accepted = rejectCode === 'FORBIDDEN' ? [200, 403].includes(response?.http) && String(response?.body?.code) === 'A0403' + : rejectCode ? response?.http === 200 && String(response?.body?.code) === rejectCode + : response?.http === 200 && String(response?.body?.code) === '200' + check(label, accepted && response?.requestId && response?.traceId, response) + return response.body?.data + } catch (error) { if (entry.status === 'SENDING') entry.status = 'UNKNOWN_RESULT'; throw error } + } + const list = () => request('GET', `${path}?page=1&size=100`, undefined, '读取本趟成本及分摊') + let departure + function verifySummary(summary, amounts) { + check('分摊锁定原始DEPART版本和装载摘要', String(summary?.departureVersion) === String(departure.recordVersion) + && summary.manifestHash === departure.snapshot.confirmedLoad.manifestHash + && summary.allocationMethod === TRIP_COST_EXPECTATIONS.allocationMethod && summary.allocationBlockReason == null) + check('原币分别汇总,未混币', Array.isArray(summary.totals) && summary.totals.length === Object.keys(amounts).length + && new Set(summary.totals.map(row => row.currency)).size === summary.totals.length) + for (const row of summary.totals) { + check('整趟成本与确定预期一致', amounts[row.currency] != null && cents(row.directCost) === cents(amounts[row.currency])) + const expected = expectedAllocations(amounts[row.currency], shares) + const actual = sorted(row.allocations).map(part => ({ businessNo: part.businessNo, sourceType: part.sourceType, + dispatchNo: part.dispatchNo, palletSpaces: Number(part.palletSpaces), cents: String(cents(part.amount)) })) + check('逐票分摊及自有/外接归属准确且分币守恒', same(actual, expected) + && cents(row.ownCost) === expected.filter(part => part.sourceType === 'OWN').reduce((sum, part) => sum + BigInt(part.cents), 0n) + && cents(row.externalCost) === expected.filter(part => part.sourceType === 'EXTERNAL').reduce((sum, part) => sum + BigInt(part.cents), 0n)) + } + } + function verifyCost(row, details, costNo, version) { + check('成本业务号、车次、版本和完整内容一致', row?.costNo && (!costNo || row.costNo === costNo) && row.tripNo === tripProof.tripNo + && Number(row.recordVersion) === version && row.details?.category === details.category && row.details.currency === details.currency + && cents(row.details.amount) === cents(details.amount) && Date.parse(row.details.incurredAt) === Date.parse(details.incurredAt) + && row.details.remark === details.remark) + } + try { + for (const actor of ['writer', 'reader', 'denied']) { + const permissions = await request('GET', AUTH, undefined, `核对${actor}实际权限`, actor) + check('权限身份准确,不能用管理员冒充拒绝样本', Array.isArray(permissions) && (actor === 'writer' + ? ['list', 'cost-create', 'cost-edit'].every(name => permissions.includes(PERM + name)) + : (permissions.includes(PERM + 'list') === (actor === 'reader')) + && !permissions.includes(PERM + 'cost-create') && !permissions.includes(PERM + 'cost-edit'))) + } + const history = await request('GET', `${base}/history?page=1&size=100`, undefined, '读取原DEPART不可变实物依据') + check('历史完整且DEPART唯一', Array.isArray(history?.records) && Number(history.total) === history.records.length + && history.records.filter(row => row.action === 'DEPART').length === 1) + departure = history.records.find(row => row.action === 'DEPART'); proof.departure = clone(departure) + const snapshot = departure.snapshot, load = snapshot?.confirmedLoad + check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo && snapshot.trip.departedAt === run.departure.departedAt + && load?.manifestHash && load.pallets?.length > 0 && load.cargo?.length > 0) + const ids = shares.flatMap(row => row.warehousePalletIds).map(String) + check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length + && same([...ids].sort(), load.pallets.map(row => String(row.warehousePalletId)).sort()) + && new Set(load.pallets.map(row => row.deliveryIndex)).size === 1 + && load.cargo.every(row => ids.includes(String(row.warehousePalletId)))) + const external = (snapshot.externalBookings || []).filter(row => !['DRAFT', 'CANCELLED'].includes(row.status)) + check('有效外接预订全为BOUND且预期完整', external.every(row => row.status === 'BOUND') + && same(external.map(row => row.bookingNo).sort(), shares.filter(row => row.sourceType === 'EXTERNAL').map(row => row.businessNo).sort())) + for (const share of shares) { + const ownedIds = share.warehousePalletIds.map(String), cargo = load.cargo.filter(row => ownedIds.includes(String(row.warehousePalletId))) + check('预期托位来自DEPART实测并对应排车', share.palletSpaces === load.pallets.filter(row => ownedIds.includes(String(row.warehousePalletId))) + .reduce((sum, row) => sum + Number(row.palletSpaces), 0) && cargo.length > 0 && cargo.every(row => row.dispatchNo === share.dispatchNo) + && ownedIds.every(id => cargo.some(row => String(row.warehousePalletId) === id))) + if (share.sourceType === 'OWN') check('自有货使用排车业务号', share.businessNo === share.dispatchNo) + else { + const booking = external.find(row => row.bookingNo === share.businessNo), receipt = booking?.receipt?.source + check('外接分摊来源为真实绑定及实收', booking?.dispatchNo === share.dispatchNo && receipt?.pallets?.length > 0 + && same(receipt.pallets.map(row => String(row.warehousePalletId)).sort(), [...ownedIds].sort()) + && cargo.every(row => String(row.customerId) === String(booking.customerId)) + && Number(receipt.receivedPackages) === cargo.reduce((sum, row) => sum + Number(row.packages), 0) + && receipt.pallets.every(pallet => Number(pallet.packages) === cargo.filter(row => String(row.warehousePalletId) === String(pallet.warehousePalletId)) + .reduce((sum, row) => sum + Number(row.packages), 0))) + } + } + const before = await list() + check('专用车次尚无成本,未知不冒充零', Number(before?.page?.total) === 0 && before.page.records.length === 0 && before.summary?.totals?.length === 0) + verifySummary(before.summary, {}) + const initial = { category: 'FUEL', currency: 'CNY', amount: TRIP_COST_EXPECTATIONS.initialCny, + incurredAt: new Date(Date.now() - 60000).toISOString(), remark: `${context.marker} 车次成本验收` } + const create = { idempotencyKey: `trip-cost-${randomUUID()}`, details: initial } + await request('POST', path, create, '列表权限不能录入成本', 'reader', 'FORBIDDEN') + await request('GET', `${path}?page=1&size=100`, undefined, '无列表权限不能读取成本', 'denied', 'FORBIDDEN') + const first = await request('POST', path, create, '录入CNY原币运营成本'); verifyCost(first, initial, null, 0) + proof.costs.push(clone(first)) + verifyCost(await request('POST', path, create, '同键重放不重复记成本'), initial, first.costNo, 0) + await request('POST', path, { ...create, details: { ...initial, amount: '100.02' } }, '同键异载荷拒绝', 'writer', 'A0409') + const usdDetails = { ...initial, category: 'TOLL', currency: 'USD', amount: TRIP_COST_EXPECTATIONS.usd } + const usd = await request('POST', path, { idempotencyKey: `trip-cost-${randomUUID()}`, details: usdDetails }, '录入USD原币运营成本') + verifyCost(usd, usdDetails, null, 0); proof.costs.push(clone(usd)) + verifySummary((await list()).summary, { CNY: '100.01', USD: '13.07' }) + const costPath = `${path}/${encodeURIComponent(first.costNo)}`, corrected = { ...initial, amount: TRIP_COST_EXPECTATIONS.finalCny } + const edit = { recordVersion: first.recordVersion, details: corrected, reason: `${context.marker} 成本更正` } + await request('PUT', costPath, edit, '列表权限不能更正成本', 'reader', 'FORBIDDEN') + const changed = await request('PUT', costPath, edit, '更正CNY成本'); verifyCost(changed, corrected, first.costNo, 1) + await request('PUT', costPath, edit, '旧版本即使内容相同仍拒绝', 'writer', 'A0409') + verifyCost(await request('PUT', costPath, { ...edit, recordVersion: changed.recordVersion }, '无内容更改不追加历史'), corrected, first.costNo, 1) + verifyCost(await request('POST', path, create, '原创建键重放保留后续更正'), corrected, first.costNo, 1) + const final = await list(); proof.final = clone(final) + check('两币种仅有两条成本,无权限及冲突请求不落库', Number(final.page?.total) === 2 && final.page.records.length === 2 + && new Set(final.page.records.map(row => row.costNo)).size === 2) + verifyCost(final.page.records.find(row => row.costNo === first.costNo), corrected, first.costNo, 1) + verifyCost(final.page.records.find(row => row.costNo === usd.costNo), usdDetails, usd.costNo, 0) + verifySummary(final.summary, { CNY: '100.03', USD: '13.07' }) + const reader = await request('GET', `${path}?page=1&size=100`, undefined, '列表权限可读取全部本趟成本', 'reader') + check('只读结果一致', same(reader, final)) + for (const row of [first, usd]) { + const historyPath = `${path}/${encodeURIComponent(row.costNo)}/history?page=1&size=100` + const history = await request('GET', historyPath, undefined, '列表权限可查成本历史', 'reader') + await request('GET', historyPath, undefined, '无列表权限不可查成本历史', 'denied', 'FORBIDDEN') + const rows = [...(history?.records || [])].sort((a, b) => Number(a.recordVersion) - Number(b.recordVersion)), isCny = row === first + check('历史动作与数量精确,无重放/无变化冗余', Number(history.total) === rows.length + && same(rows.map(item => item.action), isCny ? ['CREATE', 'UPDATE'] : ['CREATE'])) + for (let index = 0; index < rows.length; index++) { + const item = rows[index] + check('历史记录版本与原因完整', Number(item.recordVersion) === index && Boolean(item.reason) && Number.isFinite(Date.parse(item.occurredAt))) + verifyCost(item.snapshot?.cost, isCny ? index === 0 ? initial : corrected : usdDetails, row.costNo, index) + verifySummary(item.snapshot.summary, isCny && index === 0 ? { CNY: '100.01' } + : { CNY: isCny ? '100.03' : '100.01', USD: '13.07' }) + } + (proof.histories ??= []).push({ costNo: row.costNo, records: clone(rows) }) + } + proof.coverage = { currencies: ['CNY', 'USD'], sourceTypes: [...new Set(shares.map(row => row.sourceType))], + permissions: 'WRITER_READER_DENIED', customerFinance: 'NOT_TESTED_BY_THIS_MODULE' } + proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + } catch (error) { proof.status = 'FAILED'; await context.checkpoint?.(); throw error } +} diff --git a/scripts/fleet-company-cost-test-env.mjs b/scripts/fleet-company-cost-test-env.mjs new file mode 100644 index 000000000..1c3b72ece --- /dev/null +++ b/scripts/fleet-company-cost-test-env.mjs @@ -0,0 +1,99 @@ +import { readFile, mkdir, writeFile } from 'node:fs/promises' +import { parseEnv } from 'node:util' +import { resolve, dirname } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { run as verifyCompanyCost } from './admin-test-env-modules/fleet-company-cost-acceptance.mjs' + +export const MARKER = 'FLEET-COST-20261003' +export const READY_COMMIT = 'a6fd0ba3' +export function requireReady(args, execArgs) { + if (!args.includes(`--ready-commit=${READY_COMMIT}`)) throw new Error('等待总控确认目标服务就绪后,才可显式传入本轮ready-commit') + return requireTestProfile(execArgs) +} +export function requireTestProfile(execArgs) { + const joined = execArgs.find(value => value.startsWith('--env-file=')) + const index = execArgs.indexOf('--env-file') + const file = joined?.slice('--env-file='.length) || (index >= 0 ? execArgs[index + 1] : '') + if (!file || !resolve(file).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env')) + throw new Error('须在脚本前显式指定授权目录的test-acceptance.env,禁止复用其他环境或任务会话文件') + return resolve(file) +} + +/** Explicit single-task runner. It does not expand other acceptance modules or reuse their resources/sessions. */ +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const preflight = args.includes('--preflight') + const envFile = preflight ? requireTestProfile(execArgs) : requireReady(args, execArgs) + const env = parseEnv(await readFile(envFile, 'utf8')) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw new Error('TEST身份配置缺失') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) + throw new Error('TEST统一API基础地址格式与当前验收合同不符') + const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') + if (!repository.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('公司成本验收只允许在登记的TEST项目运行') + const output = resolve(repository, `reports/fleet-cost-live-20261003/${preflight ? `preflight-${new Date().toISOString().replace(/\D/g, '').slice(0,17)}` : 'api-report'}.json`) + await mkdir(dirname(output), { recursive: true }) + const context = new AcceptanceRun({ gateway: base.origin, quiet: true, modules: [] }) + context.marker = MARKER + const report = { marker: MARKER, mode: preflight ? 'READ_ONLY_PREFLIGHT' : 'BUSINESS_ACCEPTANCE', expectedCommit: READY_COMMIT, status: 'PREPARING', startedAt: new Date().toISOString(), session: { login: 'NOT_STARTED', logout: 'NOT_STARTED' }, createdResources: context.createdResources, steps: context.steps } + let refresh = '', failure + const secrets = [env.CHONGCHONG_ADMIN_PASSWORD] + async function checkpoint(first = false) { + const value = JSON.stringify(report, null, 2) + if (secrets.some(secret => secret && secret.length >= 8 && value.includes(secret))) throw new Error('报告敏感信息检查失败,停止输出') + await writeFile(output, `${value}\n`, { encoding: 'utf8', flag: first ? 'wx' : 'w' }) + } + await checkpoint(true) // Refuse automatic repeat runs over any prior evidence. + context.checkpoint = checkpoint + try { + const response = await context.request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + report.session.login = response?.http === 200 && String(response?.body?.code) === '200' ? 'SUCCESS' : 'FAILED' + report.session.loginDiagnostic = { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId } + if (report.session.login !== 'SUCCESS' || !response.body.data?.accessToken || !response.body.data?.refreshToken) throw new Error('独立TEST登录未成功,未执行业务写入') + context.token = response.body.data.accessToken; refresh = response.body.data.refreshToken + secrets.push(context.token, refresh) + report.status = 'RUNNING'; await checkpoint() + if (preflight) { + const checks = [ + ['permissions', '/api/platform/v1/iam/authorization/permissions'], + ['suppliers', '/api/platform/v1/suppliers?page=1&size=1&status=ACTIVE'], + ['exchange', `/api/platform/v1/last-leg/master-data/exchange-rates/${new Date().toISOString().slice(0,7)}`], + ] + report.preflight = {} + for (const [name, path] of checks) { + const checked = await context.request('GET', path) + report.preflight[name] = { http: checked?.http, code: checked?.body?.code, requestId: checked?.requestId, traceId: checked?.traceId } + if (checked?.http !== 200 || String(checked?.body?.code) !== '200') throw new Error(`只读预检未通过:${name}`) + if (name === 'permissions') { + const required = ['platform:supplier:create', 'platform:supplier:list', 'platform:overseas-master-data:vehicle:list', + 'platform:overseas-master-data:vehicle:create', 'platform:overseas-master-data:vehicle:status', + 'platform:overseas-master-data:vehicle:record-create', 'platform:overseas-master-data:vehicle:record-edit', + 'platform:overseas-master-data:vehicle:record-finance-submit', 'platform:finance:customer-arap:list', + ...['list','approve','void','pay','upload'].map(action => `platform:finance:vehicle-cost:${action}`)] + if (!Array.isArray(checked.body.data)) throw new Error('权限快照格式不匹配') + report.preflight.missingPermissions = required.filter(code => !checked.body.data.includes(code)) + if (report.preflight.missingPermissions.length) throw new Error('公司成本验收所需权限缺失') + } + if (name === 'exchange') report.preflight.exchange.active = checked.body.data?.status === 'ACTIVE' + await checkpoint() + } + } else await verifyCompanyCost(context) + report.status = 'VERIFIED' + } catch (error) { failure = error; report.status = 'FAILED'; report.error = error instanceof Error ? error.message : String(error) } + finally { + if (refresh) { + const response = await context.request('POST', '/api/auth/logout', { refreshToken: refresh }) + report.session.logout = response?.http === 200 && String(response?.body?.code) === '200' ? 'SUCCESS' : 'FAILED' + report.session.logoutDiagnostic = { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId } + if (report.session.logout !== 'SUCCESS') { report.status = 'FAILED'; failure ??= new Error('本任务独立TEST会话退出未确认') } + } + context.token = ''; context.refreshToken = ''; refresh = '' + report.finishedAt = new Date().toISOString() + await checkpoint() + } + return { output, status: report.status, failed: Boolean(failure), error: failure ? '验收失败,按报告中的原请求核对;未自动重新造数' : null } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.failed ? 1 : 0 }) + .catch(() => { console.error('公司成本验收未启动或报告保存失败;请核对就绪标记、TEST配置及既有报告,未输出凭据'); process.exitCode = 1 }) +} diff --git a/scripts/fleet-quotes-test-env-contract.test.mjs b/scripts/fleet-quotes-test-env-contract.test.mjs new file mode 100644 index 000000000..4b8ed444c --- /dev/null +++ b/scripts/fleet-quotes-test-env-contract.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installQuoteJournal, requireTestProfile, READY_COMMIT } from './fleet-quotes-test-env.mjs' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { run, runQuoteSession } from './admin-test-env-modules/fleet-quotes-operations.mjs' + +const ok = data => ({ http: 200, body: { code: '200', data }, requestId: 'offline-request', traceId: 'offline-trace' }) +function harness(respond = () => ok({})) { + const context = new AcceptanceRun({ environment: 'test', username: 'synthetic-user', password: 'synthetic-password', quiet: true }) + const events = [], calls = [] + const persist = async (name, value) => events.push({ name, value: structuredClone(value) }) + for (const name of ['request', 'requestWithBody', 'requestBinary']) context[name] = async (...args) => { + const before = events.at(-1) + assert.match(before.name, /-before.json$/) + assert.equal(before.value.method, args[0]); assert.equal(before.value.path, args[1]) + calls.push({ name, args }) + return respond(...args) + } + return { context, events, calls, persist } +} +test('requires exactly one explicit authorized TEST profile and the deployed commit acknowledgement', () => { + const file = 'C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env' + assert.ok(requireTestProfile([`--ready-commit=${READY_COMMIT}`], [`--env-file=${file}`])) + for (const [args, runtime] of [ + [[], [`--env-file=${file}`]], [['--ready-commit=old'], [`--env-file=${file}`]], + [[`--ready-commit=${READY_COMMIT}`], []], [[`--ready-commit=${READY_COMMIT}`], [`--env-file=${file.replace('test-', 'prod-')}`]], + [[`--ready-commit=${READY_COMMIT}`], [`--env-file=${file}`, `--env-file=${file}`]], + [[`--ready-commit=${READY_COMMIT}`, '--resume'], [`--env-file=${file}`]], + ]) assert.throws(() => requireTestProfile(args, runtime)) +}) +test('writes immutable exact business body and key before sending and pairs response diagnostics', async () => { + const h = harness(); await installQuoteJournal(h.context, '', { persist: h.persist }) + const body = { idempotencyKey: 'same-original-key', details: { looseDeliveryPrice: 0, selfPickupPrice: null } } + await h.context.request('POST', '/api/platform/v1/last-leg/fleet/quotes', body) + body.details.looseDeliveryPrice = 20 + assert.equal(h.events[1].value.body.value.details.looseDeliveryPrice, 0) + assert.equal(h.events[1].value.idempotencyKey, 'same-original-key') + assert.equal(h.events[2].value.sample, h.events[1].name) + assert.equal(h.events[2].value.requestId, 'offline-request') +}) +for (const response of [{ http: 0 }, { http: 408 }, { http: 503 }, { http: 200, body: { code: 'S0500' } }, { http: 200, body: null }]) { + test(`unknown write ${JSON.stringify(response)} halts without retry while allowing journaled logout`, async () => { + const h = harness((method, path) => path === '/api/auth/logout' ? ok(null) : response) + const journal = await installQuoteJournal(h.context, '', { persist: h.persist }) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /Unknown write/) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /halted/) + await assert.rejects(h.context.request('PATCH', '/api/platform/v1/suppliers/1/status', { status: 'INACTIVE' }), /halted/) + assert.equal(h.calls.length, 1); assert.equal(journal.unknown(), true) + await h.context.request('POST', '/api/auth/logout', { refreshToken: 'synthetic-refresh' }) + assert.equal(h.calls.length, 2); assert.equal(h.events[2].value.outcome, 'UNKNOWN_STOP') + }) +} +test('failure to persist before sending blocks all HTTP', async () => { + const h = harness(); let writes = 0 + await installQuoteJournal(h.context, '', { persist: async () => { if (++writes > 1) throw new Error('disk full') } }) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', {}), /disk full/) + assert.equal(h.calls.length, 0) +}) +test('an uncertain quote command skips all resource deactivation and retains the original supplier', async () => { + const h = harness((method, path) => path.includes('/suppliers') ? ok({ id: '321' }) : { http: 0 }) + h.context.marker = 'FLEET-QUOTE-20261003' + h.context.createdResources.merchantQualificationFixture = { marker: h.context.marker, tenantId: '123' } + await installQuoteJournal(h.context, '', { persist: h.persist }) + await assert.rejects(run(h.context), /Unknown write/) + assert.equal(h.context.createdResources.fleetQuoteAcceptance.cleanup, 'NOT_ATTEMPTED_UNKNOWN_OUTCOME') + assert.equal(h.context.createdResources.fleetQuoteAcceptance.supplierId, '321') + assert.equal(h.calls.length, 2) + assert.ok(h.calls.every(call => call.args[0] === 'POST')) +}) +test('failure to persist a response retains the before sample and forbids replay', async () => { + const h = harness() + await installQuoteJournal(h.context, '', { persist: async (name, value) => { if (name.includes('-after')) throw new Error('disk full'); await h.persist(name, value) } }) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /disk full/) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /halted/) + assert.equal(h.calls.length, 1); assert.match(h.events.at(-1).name, /before/) +}) +test('multipart checkpoint preserves upload bytes, filenames, digest and scalar fields', async () => { + const h = harness(); await installQuoteJournal(h.context, '', { persist: h.persist }) + const body = new FormData(); body.append('businessLicense', new Blob(['fixture-png'], { type: 'image/png' }), 'fixture.png'); body.append('kind', 'BUSINESS_LICENSE') + await h.context.requestWithBody('POST', '/api/platform/v1/files/qualifications/1/required-package', body) + const parts = h.events[1].value.body.parts + assert.equal(Buffer.from(parts[0].base64, 'base64').toString(), 'fixture-png') + assert.equal(parts[0].filename, 'fixture.png'); assert.equal(parts[0].sha256.length, 64) + assert.equal(parts[1].value, 'BUSINESS_LICENSE') +}) +test('login, fresh fixture and logout have durable checkpoints without credentials even when fixture outcome is unknown', async () => { + const h = harness((method, path) => path === '/api/auth/login' ? ok({ accessToken: 'synthetic-access', refreshToken: 'synthetic-refresh' }) : path === '/api/auth/logout' ? ok(null) : { http: 0 }) + await installQuoteJournal(h.context, '', { persist: h.persist, secrets: ['synthetic-password', 'synthetic-user'] }) + const report = await runQuoteSession(h.context, { prepareCustomer: async current => { await current.request('POST', '/api/platform/v1/iam/tenants', { tenantName: current.marker }) } }) + assert.equal(report.success, false); assert.equal(report.loggedOut, true) + assert.deepEqual(h.calls.map(call => call.args[1]), ['/api/auth/login', '/api/platform/v1/iam/tenants', '/api/auth/logout']) + assert.ok(h.events.some(event => event.name.includes('module-end'))) + assert.doesNotMatch(JSON.stringify(h.events), /synthetic-password|synthetic-user|synthetic-access|synthetic-refresh/) +}) +test('redacts newly issued customer credentials including echoes in diagnostic strings', async () => { + const h = harness(() => ok({ temporaryPassword: 'generated-secret', administratorUsername: 'generated-user', message: 'generated-secret generated-user' })) + await installQuoteJournal(h.context, '', { persist: h.persist }) + await h.context.request('POST', '/api/platform/v1/iam/tenants', { tenantName: 'current-run' }) + assert.doesNotMatch(JSON.stringify(h.events), /generated-secret|generated-user/) +}) +test('exclusive on-disk claim prevents a second run from replacing original evidence', async () => { + const directory = await mkdtemp(join(tmpdir(), 'fleet-quote-journal-')) + try { + const h = harness(); const original = async () => ok(null) + h.context.request = original + await installQuoteJournal(h.context, directory) + await h.context.request('POST', '/api/platform/v1/suppliers', { name: 'original-sample' }) + const files = await readdir(directory) + assert.equal(files.length, 3) + await assert.rejects(installQuoteJournal(harness().context, directory), { code: 'EEXIST' }) + assert.match(await readFile(join(directory, '00002-before.json'), 'utf8'), /original-sample/) + } finally { await rm(directory, { recursive: true, force: true }) } +}) diff --git a/scripts/fleet-quotes-test-env.mjs b/scripts/fleet-quotes-test-env.mjs new file mode 100644 index 000000000..853a71de5 --- /dev/null +++ b/scripts/fleet-quotes-test-env.mjs @@ -0,0 +1,130 @@ +import { open, mkdir, readFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { createHash } from 'node:crypto' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { runQuoteSession } from './admin-test-env-modules/fleet-quotes-operations.mjs' + +export const READY_COMMIT = 'a6fd0ba3' +export const MARKER = 'FLEET-QUOTE-20261003' +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outputDirectory = resolve(repository, 'reports/fleet-quote-live-20261003') +const sensitive = /password|token|authorization|cookie|secret|loginId|username/i + +export function requireTestProfile(args, execArgs) { + if (args.length !== 1 || args[0] !== `--ready-commit=${READY_COMMIT}`) throw new Error('Explicit current ready-commit required; no resume or replay mode') + const files = [] + for (let i = 0; i < execArgs.length; i++) { + if (execArgs[i].startsWith('--env-file=')) files.push(execArgs[i].slice(11)) + else if (execArgs[i] === '--env-file') files.push(execArgs[++i]) + else throw new Error('Only the explicit TEST env-file runtime option is allowed') + } + if (files.length !== 1 || !files[0] || !resolve(files[0]).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env')) throw new Error('Explicit authorized TEST profile required') + return resolve(files[0]) +} + +/** Append-only, fsync-before-send evidence. A fixed exclusive claim prohibits automatic reruns even after a crash. */ +export async function installQuoteJournal(context, directory, { persist, secrets = [] } = {}) { + let sequence = 0, halted = false + const knownSecrets = new Set(secrets.filter(Boolean)) + function discover(value) { + if (!value || typeof value !== 'object') return + for (const [key, child] of Object.entries(value)) { + if (sensitive.test(key) && typeof child === 'string' && child) knownSecrets.add(child) + else if (child && typeof child === 'object') discover(child) + } + } + function clean(value) { + discover(value) + if (typeof value === 'string') { + let result = value + for (const secret of knownSecrets) result = result.split(secret).join('[REDACTED]') + return result + } + if (Array.isArray(value)) return value.map(clean) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, sensitive.test(key) ? '[REDACTED]' : clean(child)])) + return value + } + const save = persist || (async (name, value) => { + const file = await open(resolve(directory, name), 'wx', 0o600) + try { await file.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); await file.sync() } finally { await file.close() } + }) + async function checkpoint(kind, value) { + const name = `${String(++sequence).padStart(5, '0')}-${kind}.json` + await save(name, clean({ marker: MARKER, at: new Date().toISOString(), ...value })) + return name + } + await checkpoint('claim', { expectedCommit: READY_COMMIT, scope: 'QUOTES_ONLY', status: 'CLAIMED', rerun: 'FORBIDDEN; reconcile original samples manually' }) + async function bodyEvidence(body) { + if (!(body instanceof FormData)) return body === undefined ? { encoding: 'absent' } : { encoding: 'json', value: structuredClone(body) } + const parts = [] + for (const [name, value] of body.entries()) { + if (typeof value === 'string') parts.push({ name, value }) + else { + const bytes = Buffer.from(await value.arrayBuffer()) + parts.push({ name, filename: value.name, contentType: value.type, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), base64: bytes.toString('base64') }) + } + } + return { encoding: 'multipart', parts } + } + for (const name of ['request', 'requestWithBody', 'requestBinary']) { + const original = context[name].bind(context) + context[name] = async (method, path, ...rest) => { + method = String(method).toUpperCase() + const logout = method === 'POST' && path === '/api/auth/logout' + if (halted && !logout) throw new Error('Unknown request outcome: halted; original sample retained, no retry or cleanup') + const body = name === 'requestBinary' ? undefined : rest[0] + discover(body) + const request = { method, path, transport: name, body: await bodyEvidence(body), headers: name === 'requestBinary' ? {} : rest[2] || {}, idempotencyKey: body?.idempotencyKey ?? null } + let sample + try { sample = await checkpoint('before', request) } catch (error) { halted = true; context.quoteRequestOutcomeUnknown = true; throw error } + let response + try { response = await original(method, path, ...rest) } + catch { response = { http: 0, body: null, requestId: null, traceId: null } } + discover(response?.body) + const code = String(response?.body?.code ?? '') + const write = !['GET', 'HEAD'].includes(method) + const known = response?.http === 200 && (code === '200' || /^A04\d+$/.test(code)) || response?.http >= 400 && response?.http < 500 && response?.http !== 408 + const unknown = write && !known + if (unknown) { halted = true; context.quoteRequestOutcomeUnknown = true } + try { + await checkpoint('after', { sample, method, path, outcome: unknown ? 'UNKNOWN_STOP' : 'RECEIVED', http: response?.http, code, requestId: response?.requestId, traceId: response?.traceId, + // Only write responses belong to our isolated fixture. Do not persist unrelated directory records. + body: write ? response?.body : undefined, createdResources: context.createdResources, steps: context.steps }) + } catch (error) { halted = true; context.quoteRequestOutcomeUnknown = true; throw error } + if (unknown) throw new Error('Unknown write outcome; reconcile persisted original request before further business actions') + return response + } + } + const executeModule = context.executeModule.bind(context) + context.executeModule = async (name, handler) => { + await checkpoint('module-start', { module: name, createdResources: context.createdResources }) + try { return await executeModule(name, handler) } + finally { await checkpoint('module-end', { module: name, createdResources: context.createdResources, steps: context.steps, modules: context.modules }) } + } + return { checkpoint, unknown: () => halted } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const file = requireTestProfile(args, execArgs) + if (!repository.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('Only registered TEST workspace is allowed') + const env = parseEnv(await readFile(file, 'utf8')) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw new Error('TEST credentials missing') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw new Error('Invalid unified TEST API base') + await mkdir(outputDirectory, { recursive: true }) + const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, quiet: true, modules: [] }) + const journal = await installQuoteJournal(context, outputDirectory, { secrets: [env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD] }) + let report + try { report = await runQuoteSession(context, { marker: MARKER }) } + catch { report = { success: false, fatal: 'Session interrupted; inspect durable request checkpoints', operationsStatus: 'NOT_TESTED' } } + finally { context.token = ''; context.refreshToken = '' } + const success = Boolean(report.success) && !journal.unknown() + await journal.checkpoint('final', { ...report, success, outcome: journal.unknown() ? 'UNKNOWN_STOP' : success ? 'VERIFIED' : 'FAILED', createdResources: context.createdResources }) + return { outputDirectory, success, realExecution: true } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.success ? 0 : 1 }) + .catch(() => { console.error('报价验收未启动或已中止;核对TEST配置、就绪标记和持久检查点,禁止自动重跑。'); process.exitCode = 1 }) +} diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index e530d42e9..471b485ad 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs' +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs' import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { createHash, randomUUID } from 'node:crypto' @@ -10,6 +10,8 @@ import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-bo import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs' import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' +import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' +import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' @@ -30,27 +32,30 @@ export function parseFleetArguments(args) { if (action) throw Error('Choose exactly one plan or execute action') action = name; config.execute = name === '--execute'; continue } - if (!['--environment', '--run-id', '--resume-report', '--resume-stage'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') + if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') if (seen.has(name)) throw Error('Duplicate argument') seen.add(name) const value = args[++index] if (name === '--environment') config.environment = value else if (name === '--run-id') config.runId = value + else if (name === '--output') config.outputDir = value else if (name === '--resume-report') config.resumeReport = value else config.resumeStage = value } if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') return config } export function fleetExecutionPlan(config) { return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, - outputDir: `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites', + outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', - 'atomic whole-trip departure', 'per-destination receipt and trip resource release'], + 'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', + 'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'], boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.', limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' } } @@ -158,8 +163,122 @@ export async function verifyFleetTerminalState(context) { return proof } -export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { +export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) { + const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails) + const run = context.createdResources.fleetAcceptance + if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo + || run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser + || trip.costAcceptance?.status !== 'VERIFIED' || trip.costAcceptance.marker !== context.marker) + throw Error('Only the current verified DEPART and handover can publish a shared fixture') + const fixture = { marker: context.marker, runId: context.context?.fleetRunId, tripNo: trip.tripNo, warehouseId: trip.warehouseId, dispatchNos: state.dispatchNos, + departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo, + originalResources: state.previous, replacementResources: state.replacement, browser: state.browser, + costs: trip.costAcceptance, + status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount } + context.context?.saveFleetHandoverFixture?.(fixture) + if (onReady) await onReady(fixture) + return fixture +} + +export function fleetCostShares(trip, dispatchDetails) { + const external = trip.externalReceipt, externalDispatch = trip.externalDispatch + if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId || !externalDispatch?.dispatchNo) + throw Error('Cost expectations require this run\'s verified external receipt') + const shares = dispatchDetails.map(row => { + const pallets = [...new Set((row.items || []).map(item => id(item.warehousePalletId)))] + if (!row.dispatchNo || !pallets.length || pallets.some(value => !value)) throw Error('Cost expectations require actual dispatch pallets') + const isExternal = row.dispatchNo === externalDispatch.dispatchNo + if (isExternal && (pallets.length !== 1 || pallets[0] !== id(external.warehousePalletId))) throw Error('External cost source does not match actual receipt') + // The fixture's explicit measurement rule is one space per physical pallet; never derive this from cost totals. + return { businessNo: isExternal ? external.bookingNo : row.dispatchNo, sourceType: isExternal ? 'EXTERNAL' : 'OWN', + dispatchNo: row.dispatchNo, warehousePalletIds: pallets, palletSpaces: pallets.length } + }) + const pallets = shares.flatMap(row => row.warehousePalletIds) + if (shares.filter(row => row.sourceType === 'EXTERNAL').length !== 1 || !shares.some(row => row.sourceType === 'OWN') + || new Set(pallets).size !== pallets.length) throw Error('Cost fixture requires distinct own and external physical pallets') + return shares +} + +export async function prepareFleetCostActors(context) { + if (!context.marker || context.createdResources.fleetCostActors || typeof context.checkpoint !== 'function') + throw Error('Cost actors require fresh current-run ownership and durable checkpoints') + const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CREATING', actors: [], requests: [] } + const base = '/api/platform/v1/iam', sessions = [] + async function request(method, path, body, token) { + const entry = { method, path, body: redactEvidence(body), status: 'SENDING' } + proof.requests.push(entry); await context.checkpoint() + try { + const response = await context.request(method, path, body, token) + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId, result: redactEvidence(response?.body?.data) }) + await context.checkpoint() + if (!success(response) || !response.requestId || !response.traceId) throw Error(`Cost actor request failed: ${method} ${path}`) + return response.body.data + } catch (error) { + if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } + throw error + } + } + async function close() { + const errors = [] + for (const actor of proof.actors) { + try { + if (actor.membershipId && !actor.userDisabled) { + const result = await request('PUT', `${base}/users/${actor.membershipId}/status`, { status: 'DISABLED' }) + if (result?.status !== 'DISABLED') throw Error('Cost test account was not disabled') + actor.userDisabled = true + } + if (actor.roleId && !actor.roleDisabled) { + const result = await request('PUT', `${base}/roles/${actor.roleId}/status`, { status: 'DISABLED' }) + if (result?.status !== 'DISABLED') throw Error('Cost test role was not disabled') + actor.roleDisabled = true + } + } catch (error) { errors.push(error) } + } + proof.status = errors.length ? 'CLEANUP_FAILED' : 'DISABLED'; await context.checkpoint() + if (errors.length) throw new AggregateError(errors, 'Cost actor cleanup requires review; do not recreate accounts') + } + try { + const permissions = await request('GET', `${base}/permissions`) + const list = permissions.filter(row => row.permissionCode === 'platform:fleet:trip:list' && row.status === 'ACTIVE') + if (list.length !== 1 || !list[0].id) throw Error('Active trip list permission missing or ambiguous') + for (const kind of ['reader', 'denied']) { + const suffix = randomUUID().replaceAll('-', '').slice(0, 16), username = `fleet_${kind}_${suffix}` + const actor = { kind, username }; proof.actors.push(actor) + const roleCode = `FLEET_${kind.toUpperCase()}_${suffix.toUpperCase()}` + const role = await request('POST', `${base}/roles`, { roleCode, roleName: `${context.marker}-${kind}`, description: context.marker }) + if (!id(role?.id) || role.roleCode !== roleCode || role.roleType !== 'CUSTOM') throw Error('Fresh cost role ownership mismatch') + actor.roleId = id(role.id) + await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] }) + const password = `Fleet${randomUUID().replaceAll('-', '')}A9` + const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`, + phone: await context.findAvailablePhone(MODULE), email: `${username}@example.com`, tenantId: '0', + roleIds: [actor.roleId], password, status: 'ACTIVE' }) + if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0' + || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch') + actor.membershipId = id(user.membershipId) + const login = await request('POST', '/api/auth/login', { loginId: username, password }, '') + if (!login?.accessToken || login.accessToken === context.token || sessions.includes(login.accessToken)) throw Error('Independent cost session missing') + sessions.push(login.accessToken) + const actual = await request('GET', `${base}/authorization/permissions`, undefined, login.accessToken) + const expected = kind === 'reader' ? ['platform:fleet:trip:list'] : [] + if (!Array.isArray(actual) || JSON.stringify([...actual].sort()) !== JSON.stringify(expected)) throw Error('Cost actor effective permissions differ from exact scope') + } + proof.status = 'READY'; await context.checkpoint() + return { marker: context.marker, listOnlyToken: sessions[0], deniedToken: sessions[1], close } + } catch (error) { + proof.status = 'FAILED'; await context.checkpoint() + try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Cost actor creation and cleanup failed') } + throw error + } +} + +export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady, + costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { if (terminalReadOnly) return verifyFleetTerminalState(context) + if (costActors?.marker !== context.marker || !costActors.listOnlyToken || !costActors.deniedToken + || costActors.listOnlyToken === costActors.deniedToken || [costActors.listOnlyToken, costActors.deniedToken].includes(context.token)) + throw Error('Prepare two independent current-run platform cost permission sessions before starting the fleet scenario') const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance : context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) } @@ -300,6 +419,15 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) proof.departure = departed + stage('TRIP_OPERATING_COSTS') + await verifyFleetTripCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + stage('IN_TRANSIT_HANDOVER') + await runFleetHandoverStage(context, trip, picker.token, dispatchDetails, { + onReady: onHandoverReady ? async fixture => { + stage('PDA_UI_CHECKPOINT') + await onHandoverReady({ fixture, picker }) + } : undefined, + }) stage('RECEIPTS_AND_RESOURCE_RELEASE') proof.receipts = [] for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { @@ -311,19 +439,24 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol } proof.closure = await finishFleetTrip(context, MODULE, trip) check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + stage('HANDOVER_RESOURCE_CLEANUP') + await cleanupFleetHandoverResources(context, MODULE, trip) proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') } finally { await disablePicker(context, picker, 71) } return proof } -export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, root = ROOT } = {}) { +export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) { if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test' || basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required') + if (scenario === runFleetScenario && config.resumeStage !== 'TERMINAL_READONLY' && typeof prepareCostActors !== 'function') + throw Error('Current-run cost actor preparation and lifecycle must be provided before any environment requests') const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || /(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname)) throw Error('Invalid TEST gateway') for (const name of ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) if (!env[name]) throw Error(`Missing ${name}`) - const output = resolve(root, `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output) + if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Report scope invalid') + const output = resolve(root, config.outputDir || `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output) if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid') mkdirSync(resolve(root, 'reports'), { recursive: true }) const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') @@ -351,6 +484,23 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c pdaUsername: env.ADMIN_PDA_USERNAME, pdaPassword: env.ADMIN_PDA_PASSWORD, merchantUsername: '', merchantPassword: '', markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) context = createContext ? createContext(options) : new AcceptanceRun(options) + context.context.fleetRunId = config.runId + context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) + let handoverCheckpoint = 0 + context.context.checkpointFleetHandover = proof => { + const payload = JSON.stringify(redactEvidence({ marker: context.marker, runId: config.runId, + proof, createdResources: context.createdResources }, [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n' + // Never truncate the prior checkpoint: interruption or disk failure leaves the earlier request recoverable. + const descriptor = openSync(join(output, 'evidence', `handover-${String(++handoverCheckpoint).padStart(6, '0')}.json`), 'wx') + try { writeFileSync(descriptor, payload); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + } + context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) + const fleetScenario = async (ctx, options = {}) => { + const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) + try { return await scenario(ctx, { ...options, costActors }) } + finally { await costActors?.close() } + } let evidenceNumber = 0 context.context.phase4ReceivingEvidenceReader = async scope => { const evidence = await readers.readReceiving(scope) @@ -362,7 +512,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c save('progress.json', { stage: name, steps: context.steps, modules: context.modules, createdResources: context.createdResources }) if (context.steps.some(step => step.passed === false) || context.modules.at(-1)?.success === false) throw Error(`Producer stopped: ${name}`) if (name === 'phase4-overseas-dispatch') { - await executeModule(MODULE, () => scenario(context)) + await executeModule(MODULE, () => fleetScenario(context)) if (context.modules.at(-1)?.success === false) throw Error('Fleet case stopped at original fixture') } } @@ -371,7 +521,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed') context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' - await context.executeModule(MODULE, () => scenario(context, { resumeFull: !resumed.receiptReady, + await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 0760b7040..e7f7a7713 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, dirname, resolve } from 'node:path' -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState } from './fleet-test-env-acceptance.mjs' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors } from './fleet-test-env-acceptance.mjs' test('fleet entry defaults to a no-side-effect plan and requires explicit TEST and unique run ID', () => { const p = parseFleetArguments([]) @@ -13,6 +13,93 @@ test('fleet entry defaults to a no-side-effect plan and requires explicit TEST a assert.throws(() => parseFleetArguments(args)) } assert.equal(parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'new']).execute, true) + const handover = parseFleetArguments(['--plan', '--environment', 'TEST', '--run-id', 'fleet-handover-20261003', '--output', 'reports/fleet-handover-live-20261003']) + assert.equal(fleetExecutionPlan(handover).outputDir, 'reports/fleet-handover-live-20261003') + for (const output of ['reports/../outside', 'C:/temp/output', 'reports', 'reports/nested/path']) assert.throws(() => parseFleetArguments(['--output', output])) +}) + +test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { + const order = [], departure = { tripNo: 'TR-NEW', status: 'IN_TRANSIT' } + const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, + createdResources: { fleetAcceptance: { departure } } } + const trip = { tripNo: 'TR-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] + const verified = { status: 'VERIFIED', marker: 'CURRENT', originalDeparture: { snapshot: { confirmedLoad: { pallets: [] } } }, + browser: { tripNo: 'TR-NEW' }, handoverNo: 'HO-NEW' } + const result = await runFleetHandoverStage(context, trip, 'secret-picker-token', details, { + verify: async (ctx, module, proof, token, dispatches) => { order.push('verify'); assert.equal(ctx, context); assert.equal(proof, trip); assert.equal(token, 'secret-picker-token'); assert.equal(dispatches, details); return verified }, + onReady: async fixture => { order.push('ui'); assert.equal(fixture.status, 'DEPART_AND_HANDOVER_VERIFIED'); assert.equal(fixture.departure, departure) }, + }) + assert.deepEqual(order, ['verify', 'save', 'ui']); assert.ok(!JSON.stringify(result).includes('secret-picker-token')) + assert.deepEqual(result.costs, trip.costAcceptance) + for (const invalid of [{ ...verified, status: 'FAILED' }, { ...verified, marker: 'OLD' }, { ...verified, originalDeparture: null }]) { + await assert.rejects(runFleetHandoverStage(context, trip, 'token', details, { verify: async () => invalid, + onReady: async () => assert.fail('invalid evidence must not reach the browser checkpoint') }), /current verified/) + } +}) + +test('cost shares use controlled physical pallets and the original external booking, never statistical totals', () => { + const trip = { externalReceipt: { status: 'VERIFIED', bookingNo: 'BK-ONE', warehousePalletId: '3' }, externalDispatch: { dispatchNo: 'EXT' } } + const details = [{ dispatchNo: 'OWN', items: [{ warehousePalletId: '1' }, { warehousePalletId: '1' }, { warehousePalletId: '2' }] }, + { dispatchNo: 'EXT', items: [{ warehousePalletId: '3' }] }] + assert.deepEqual(fleetCostShares(trip, details), [ + { businessNo: 'OWN', sourceType: 'OWN', dispatchNo: 'OWN', warehousePalletIds: ['1', '2'], palletSpaces: 2 }, + { businessNo: 'BK-ONE', sourceType: 'EXTERNAL', dispatchNo: 'EXT', warehousePalletIds: ['3'], palletSpaces: 1 }, + ]) + assert.throws(() => fleetCostShares(trip, [{ ...details[0], items: [{ warehousePalletId: '3' }] }, details[1]]), /distinct/) + assert.throws(() => fleetCostShares(trip, [details[0], { ...details[1], items: [{ warehousePalletId: '4' }] }]), /receipt/) +}) + +test('missing isolated cost actor preparation rejects before readers or fixture creation', async () => { + let touched = false + await assert.rejects(runFleetAcceptance({ execute: true, environment: 'TEST' }, { + env: { CHONGCHONG_ENV: 'test' }, prepareCostActors: null, readers: { preflight() { touched = true } }, + }), /actor preparation/) + assert.equal(touched, false) +}) + +for (const failLogin of [false, true]) test(`cost actors create exact isolated roles and disable only owned accounts; login failure=${failLogin}`, async () => { + const calls = [], saved = [], roles = [], users = [] + const context = { marker: 'RUN', token: 'admin-secret', createdResources: {}, async findAvailablePhone() { return '19900000000' }, + async checkpoint() { saved.push(structuredClone(context.createdResources)) }, + async request(method, path, body, token) { + const pending = saved.at(-1).fleetCostActors.requests.at(-1) + assert.equal(pending.status, 'SENDING'); assert.equal(pending.path, path) + calls.push({ method, path, body, token }) + let data + if (path.endsWith('/authorization/permissions')) data = token === 'session-1' ? ['platform:fleet:trip:list'] : [] + else if (path.endsWith('/permissions') && method === 'GET') data = [{ id: '7', permissionCode: 'platform:fleet:trip:list', status: 'ACTIVE' }] + else if (path.endsWith('/roles') && method === 'POST') { data = { ...body, id: String(roles.length + 1), roleType: 'CUSTOM' }; roles.push(data) } + else if (path.endsWith('/roles/permissions')) data = body + else if (path.endsWith('/users') && method === 'POST') { data = { ...body, membershipId: String(users.length + 10) }; users.push(data) } + else if (path === '/api/auth/login') { if (failLogin) throw Error('login unavailable'); data = { accessToken: `session-${users.length}` } } + else if (path.endsWith('/status')) data = body + else assert.fail(path) + return { http: 200, body: { code: '200', data }, requestId: 'request', traceId: 'trace' } + } } + if (failLogin) await assert.rejects(prepareFleetCostActors(context), /login unavailable/) + else { + const actors = await prepareFleetCostActors(context) + assert.equal(actors.listOnlyToken, 'session-1'); assert.equal(actors.deniedToken, 'session-2') + assert.equal(calls.filter(row => row.path.endsWith('/roles/permissions'))[1].body.permissionIds.length, 0) + await actors.close() + const count = calls.length; await actors.close(); assert.equal(calls.length, count) + } + assert.equal(context.createdResources.fleetCostActors.status, 'DISABLED') + assert.equal(calls.filter(row => /\/users\/\d+\/status$/.test(row.path)).length, users.length) + assert.equal(calls.filter(row => /\/roles\/\d+\/status$/.test(row.path)).length, roles.length) + const evidence = JSON.stringify(saved) + assert.ok(!evidence.includes('session-1')); assert.ok(!evidence.includes('admin-secret')) + for (const user of users) assert.ok(!evidence.includes(user.password)) + assert.ok(!calls.some(row => row.method === 'DELETE')) +}) + +test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { + const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') + const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") + const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await finishFleetTrip') + const cleanup = source.indexOf('await cleanupFleetHandoverResources(context, MODULE, trip)') + assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) + assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) }) async function executeFixture(failProducer = false) { @@ -35,7 +122,19 @@ async function executeFixture(failProducer = false) { }) try { const options = { root, env, createContext, readers: { async preflight() { calls.push('preflight') }, async readReceiving() { throw Error('not used') } }, - scenario: async context => { calls.push('fleet'); context.createdResources.fleetAcceptance = { status: 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP' } } } + scenario: async context => { + calls.push('fleet'); context.createdResources.fleetAcceptance = { status: 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP' } + const command = { requests: [{ method: 'POST', status: 'SENDING', body: { idempotencyKey: 'original-key' }, accessToken: secrets }] } + await context.context.checkpointFleetHandover(command) + const firstPath = join(root, 'reports/fleet-fixture/evidence/handover-000001.json') + const before = readFileSync(firstPath, 'utf8'), saved = JSON.parse(before) + assert.equal(saved.proof.requests[0].body.idempotencyKey, 'original-key') + assert.equal(saved.proof.requests[0].status, 'SENDING'); assert.ok(!before.includes(secrets)) + command.requests[0].status = 'RESPONDED' + await context.context.checkpointFleetHandover(command) + assert.equal(readFileSync(firstPath, 'utf8'), before) + assert.equal(JSON.parse(readFileSync(join(root, 'reports/fleet-fixture/evidence/handover-000002.json'), 'utf8')).proof.requests[0].status, 'RESPONDED') + } } const result = await runFleetAcceptance(config, options) const report = JSON.parse(readFileSync(result.report, 'utf8')) assert.ok(!readFileSync(result.report, 'utf8').includes(secrets)) -- 2.54.0 From 780d37e1d2babeeaf68e46950f0b5e23cbed4ea3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 14:57:51 +0800 Subject: [PATCH 098/176] fix(order): paginate merchant workbench with scoped queue counts --- firstleg/AGENTS.md | 1 + .../InternalInboundOrderController.java | 9 ++ .../dto/MerchantWorkbenchFilters.java | 17 +++ .../mapper/MerchantWorkbenchFilterMapper.java | 66 +++++++++++ .../MerchantWorkbenchFilterService.java | 19 +++ .../service/UnifiedInboundQueryService.java | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 31 +++++ ...alInboundOrderReferenceControllerTest.java | 11 ++ .../MerchantWorkbenchFilterMapperTest.java | 80 +++++++++++++ order/AGENTS.md | 1 + .../client/FirstlegUnifiedOrderClient.java | 8 ++ .../MerchantUnifiedOrderController.java | 10 ++ .../dto/MerchantOrderWorkbenchQuery.java | 23 ++++ .../dto/MerchantOrderWorkbenchResponse.java | 9 ++ .../MerchantOrderWorkbenchRepository.java | 71 +++++++++++ .../MerchantOrderWorkbenchService.java | 43 +++++++ .../service/UnifiedOrderQueryService.java | 15 ++- .../src/main/resources/openapi/order-v1.yaml | 45 ++++++- .../MerchantOrderWorkbenchControllerTest.java | 70 +++++++++++ .../UnifiedOrderApiContractTest.java | 4 +- .../MerchantOrderWorkbenchRepositoryTest.java | 110 ++++++++++++++++++ .../MerchantOrderWorkbenchServiceTest.java | 66 +++++++++++ 22 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java create mode 100644 order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java create mode 100644 order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java create mode 100644 order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java create mode 100644 order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java create mode 100644 order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java create mode 100644 order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3279bbcd3..38d0741ce 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -17,6 +17,7 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | +| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | | Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index b8f0ced52..88cd299c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -43,6 +43,7 @@ public class InternalInboundOrderController { private InboundCartonLabelService cartonLabels; @Resource private UnifiedInboundQueryService unifiedQueryService; + @Resource private com.baiyue.firstleg.service.MerchantWorkbenchFilterService workbenchFilters; @Resource private OrderDeclarationSyncService declarationSync; @Resource @@ -148,6 +149,14 @@ public class InternalInboundOrderController { return ApiResponse.success(remarksSync.sync(command)); } + @PostMapping("/unified-orders/workbench-filters") + public ApiResponse workbenchFilters( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request request) { + requireToken(token); + return ApiResponse.success(workbenchFilters.read(request)); + } + @PostMapping("/unified-orders/query") public ApiResponse> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java new file mode 100644 index 000000000..5866dc917 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Pattern; +import java.util.List; + +/** Customer-scoped filter identifiers; never a replacement order list or cross-schema query. */ +public final class MerchantWorkbenchFilters { + private MerchantWorkbenchFilters() {} + + public record Request(@NotNull @Positive Long tenantId, + @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {} + + public record Response(List matchingStatusOrderIds, List feeOrderIds, + List customsOrderIds) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java new file mode 100644 index 000000000..214c45ba0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface MerchantWorkbenchFilterMapper { + @Select(""" + + """) + List byProcessStatuses(@Param("tenantId") long tenantId, @Param("statuses") List statuses); + + @Select(""" + SELECT DISTINCT i.order_id + FROM fl_inbound_order i + JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id + WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL + AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL + AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer + WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id + AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id))) + ORDER BY i.order_id + """) + List pendingFees(@Param("tenantId") long tenantId); + + @Select(""" + SELECT DISTINCT i.order_id + FROM fl_inbound_order i + JOIN fl_loading_plan p ON p.id=( + SELECT latest.id FROM fl_loading_plan latest + JOIN fl_loading_plan_item li ON li.loading_plan_id=latest.id AND li.deleted=0 + JOIN fl_inbound_order own ON own.id=li.inbound_id AND own.tenant_id=li.account_id AND own.deleted=0 + WHERE latest.plan_status='COMPLETED' AND own.tenant_id=i.tenant_id + AND own.order_id=i.order_id AND own.tax_mode='TAX_REFUND' + ORDER BY COALESCE(latest.confirmed_at,latest.updated_at) DESC,latest.id DESC LIMIT 1 + ) + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + LEFT JOIN fl_customs_container c ON c.loading_plan_id=p.id + WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.tax_mode='TAX_REFUND' + AND i.process_status IN ('LOADED','DELIVERED') + AND (c.id IS NULL OR c.status='WAITING_CUSTOMER_DOCUMENTS') + AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item item + JOIN fl_inbound_order member ON member.id=item.inbound_id + AND member.tenant_id=item.account_id AND member.deleted=0 + WHERE item.loading_plan_id=p.id AND item.deleted=0 + AND member.tenant_id=i.tenant_id AND member.tax_mode='TAX_REFUND' + AND NOT EXISTS ( + SELECT 1 FROM fl_customs_customer_document_inbound scope + JOIN fl_customs_customer_document d ON d.id=scope.document_id + AND d.customs_container_id=c.id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' + WHERE scope.inbound_id=member.id AND scope.tenant_id=i.tenant_id + ) + ) + ORDER BY i.order_id + """) + List pendingCustoms(@Param("tenantId") long tenantId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java new file mode 100644 index 000000000..785d506db --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; +import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +public class MerchantWorkbenchFilterService { + @Resource private MerchantWorkbenchFilterMapper mapper; + @Resource private UnifiedInboundQueryService unified; + + public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { + return new MerchantWorkbenchFilters.Response( + request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), + unified.internalStatuses(request.mainStatus())), + mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index e4848fdd0..7ec66f537 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -199,7 +199,7 @@ public class UnifiedInboundQueryService { return mainStatus != null && Set.of("LOADING", "LOADED", "DELIVERED").contains(mainStatus); } - private List internalStatuses(String mainStatus) { + List internalStatuses(String mainStatus) { if (!StringUtils.hasText(mainStatus)) return List.of(); return switch (mainStatus) { case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index faa977f34..7f6c33527 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -352,6 +352,37 @@ paths: operatorName: {type: string, nullable: true, maxLength: 128} responses: '200': {description: data 返回已同步的预装明细数量} + /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: + post: + summary: 查询单一客户工作台履约筛选标识(Order内部) + description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。 + security: [{internalServiceToken: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + responses: + '200': + description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + required: [feeOrderIds, customsOrderIds] + properties: + matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} + feeOrderIds: {type: array, items: {type: integer, format: int64}} + customsOrderIds: {type: array, items: {type: integer, format: int64}} /internal/firstleg/v1/inbounds/unified-orders/query: post: summary: 按统一订单条件分页查询履约摘要(Order 内部) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 59a083a37..56d55faca 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -40,9 +40,20 @@ class InternalInboundOrderReferenceControllerTest { @MockBean private OrderRemarksSyncService remarksSync; @MockBean private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot; + @MockBean private com.baiyue.firstleg.service.MerchantWorkbenchFilterService workbenchFilters; @Resource private InternalInboundOrderController controller; + @Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() { + var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED"); + assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class); + org.mockito.Mockito.verifyNoInteractions(workbenchFilters); + var response = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Response(List.of(1L), List.of(), List.of()); + when(workbenchFilters.read(request)).thenReturn(response); + assertThat(controller.workbenchFilters("internal-token", request).data()).isEqualTo(response); + } + @Test void completionSnapshotRequiresInternalTokenAndDelegatesExactCommand() { var command = new com.baiyue.firstleg.dto.OrderFulfillmentCompleted(11, 21, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java new file mode 100644 index 000000000..fcd01ece1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class MerchantWorkbenchFilterMapperTest { + private JdbcTemplate jdbc; + private MerchantWorkbenchFilterMapper mapper; + @BeforeEach void setup() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc=new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))"); + jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)"); + jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR(32))"); + jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT PRIMARY KEY,loading_plan_id BIGINT,status VARCHAR(40))"); + jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT PRIMARY KEY,customs_container_id BIGINT,tenant_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT,tenant_id BIGINT)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class); + jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); + jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)"); + } + + @Test void latestFeeVersionAndCustomerScopeDetermineConfirmationQueue() { + jdbc.update("INSERT INTO fl_inbound_fee VALUES(10,11,1,1,'SENT',NULL),(11,11,1,2,'DRAFT',NULL),(12,11,2,1,'SENT',NULL),(13,22,3,1,'SENT',NULL),(14,11,4,1,'SENT',NULL)"); + assertThat(mapper.pendingFees(11)).containsExactly(102L); + jdbc.update("INSERT INTO fl_inbound_fee VALUES(15,11,2,1,'SENT',CURRENT_TIMESTAMP)"); + assertThat(mapper.pendingFees(11)).isEmpty(); + assertThat(mapper.pendingFees(22)).containsExactly(103L); + assertThat(mapper.byProcessStatuses(11,List.of("LOADED"))).containsExactly(101L,105L); + } + + @Test void absentAggregateIsReadOnlyAndWaitingForOwnDocumentsNotAnotherCustomer() { + assertThat(mapper.pendingCustoms(11)).containsExactly(101L,102L); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_customs_container",Integer.class)).isZero(); + jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'WAITING_CUSTOMER_DOCUMENTS')"); + jdbc.update("INSERT INTO fl_customs_customer_document VALUES(201,200,11,'ACTIVE')"); + jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(201,1,11)"); + assertThat(mapper.pendingCustoms(11)).containsExactly(101L,102L); + jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(201,2,11)"); + assertThat(mapper.pendingCustoms(11)).isEmpty(); + assertThat(mapper.pendingCustoms(22)).containsExactly(103L); + } + + @Test void foreignOrDeletedDocumentsCannotSatisfyOwnCoverageAndClosedContainersAreExcluded() { + jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'WAITING_CUSTOMER_DOCUMENTS')"); + jdbc.update("INSERT INTO fl_customs_customer_document VALUES(201,200,22,'ACTIVE'),(202,200,11,'DELETED')"); + jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(201,1,11),(202,2,11)"); + assertThat(mapper.pendingCustoms(11)).containsExactly(101L,102L); + for(String status:List.of("READY_TO_START","DECLARING","COMPLETED")) { + jdbc.update("UPDATE fl_customs_container SET status=?",status); + assertThat(mapper.pendingCustoms(11)).isEmpty(); + } + } + + @Test void onlyLatestCompletedPlanCanCreateTheCurrentUploadQueue() { + jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'WAITING_CUSTOMER_DOCUMENTS')"); + jdbc.update("INSERT INTO fl_container_booking VALUES(101,'ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_plan VALUES(101,101,'COMPLETED',TIMESTAMP '2099-01-01 00:00:00',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(101,1,11,0)"); + jdbc.update("INSERT INTO fl_customs_container VALUES(201,101,'COMPLETED')"); + assertThat(mapper.pendingCustoms(11)).containsExactly(102L); + jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED' WHERE id=100"); + assertThat(mapper.pendingCustoms(11)).isEmpty(); + } +} diff --git a/order/AGENTS.md b/order/AGENTS.md index e6acbabff..23ad5d926 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -12,6 +12,7 @@ | 目标 | 入口 | |---|---| | 平台订单 API | `controller/PlatformFirstLegOrderController.java` | +| 客户订单工作台分页与待办 | `controller/MerchantUnifiedOrderController.java` 的 `/workbench`、`service/MerchantOrderWorkbenchService.java`、`repository/MerchantOrderWorkbenchRepository.java`;客户取认证上下文,筛选后在 Order 计算总数并分页,Firstleg 经内部服务身份提供履约状态、费用确认和报关待上传范围;契约 `src/main/resources/openapi/order-v1.yaml` | | 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 | | 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` | | Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序;`POST /batch` 按最多 100 个订单 ID 批量核验待审核状态,供同柜建组幂等准备 Firstleg `PREPARING` 投影 | diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 0faf2484f..025557a32 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -16,6 +16,14 @@ import java.util.List; @FeignClient(name = "firstleg-service", contextId = "orderFirstlegUnifiedOrderClient", url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds/unified-orders") public interface FirstlegUnifiedOrderClient { + @PostMapping("/workbench-filters") + ApiResponse workbenchFilters(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody WorkbenchFilterRequest request); + + record WorkbenchFilterRequest(long tenantId, String mainStatus) {} + record WorkbenchFilters(List matchingStatusOrderIds, List feeOrderIds, + List customsOrderIds) {} + @PostMapping("/query") ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Query request); diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java index 6c30ba57a..d977e7523 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java @@ -28,6 +28,16 @@ import java.util.List; public class MerchantUnifiedOrderController { @Resource private UnifiedOrderQueryService service; @Resource private OrderFulfillmentSplitService splitService; + @Resource private com.baiyue.order.service.MerchantOrderWorkbenchService workbench; + + @GetMapping("/workbench") + @PreAuthorize("hasAuthority('merchant:order:list')") + @Operation(summary = "客户订单工作台分页与待办数量") + public ApiResponse workbench( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid com.baiyue.order.dto.MerchantOrderWorkbenchQuery query) { + return ApiResponse.success(workbench.page(OrderScopeGuard.merchant(user),query)); + } @GetMapping @PreAuthorize("hasAuthority('merchant:order:list')") diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java new file mode 100644 index 000000000..842fdfad5 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java @@ -0,0 +1,23 @@ +package com.baiyue.order.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +public record MerchantOrderWorkbenchQuery( + @Size(max=64) String cargoKeyword, Long receivingWarehouseId, + @Size(max=128) String channelName, + @Pattern(regexp="FBA|OVERSEAS|PRIVATE") String destinationType, + @Size(max=128) String trackingNumber, + @Pattern(regexp="DRAFT|PENDING_REVIEW|WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED|COMPLETED|CANCELLED") String mainStatus, + LocalDateTime createdFrom, LocalDateTime createdTo, + @Pattern(regexp="all|fee|customs|draft") String queue, + @Min(1) long page, @Min(1) @Max(200) long size) { + public MerchantOrderWorkbenchQuery { + if (page==0) page=1; + if (size==0) size=10; + if (queue==null) queue="all"; + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java new file mode 100644 index 000000000..f5a0b3f83 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java @@ -0,0 +1,9 @@ +package com.baiyue.order.dto; + +import com.baiyue.common.core.api.PageResult; +import java.util.List; + +public record MerchantOrderWorkbenchResponse(PageResult page, + Counts queues, List channelNames) { + public record Counts(long all, long fee, long customs, long draft) {} +} diff --git a/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java new file mode 100644 index 000000000..a3f0ede92 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java @@ -0,0 +1,71 @@ +package com.baiyue.order.repository; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.order.dto.MerchantOrderWorkbenchQuery; +import com.baiyue.order.dto.MerchantOrderWorkbenchResponse.Counts; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.OrderMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.util.List; + +@Repository +public class MerchantOrderWorkbenchRepository { + @Resource private OrderMapper mapper; + private static final List ACTIVE=List.of("DRAFT","SUBMITTED","APPROVED","ACCEPTED","PROCESSING","COMPLETED"); + public record Result(PageResult page, Counts queues, List channelNames) {} + + @Transactional(readOnly=true) + public Result read(long customerId, MerchantOrderWorkbenchQuery q, List statusIds, + List feeIds, List customsIds) { + var base=base(customerId,q,statusIds); + long all=mapper.selectCount(base.clone()); + long fee=mapper.selectCount(ids(base.clone(),feeIds)); + long customs=mapper.selectCount(ids(base.clone(),customsIds)); + long draft=mapper.selectCount(base.clone().eq("status","DRAFT")); + var counts=new Counts(all,fee,customs,draft); + long total=switch(q.queue()) {case "fee"->fee;case "customs"->customs;case "draft"->draft;default->all;}; + if ("fee".equals(q.queue())) ids(base,feeIds); + if ("customs".equals(q.queue())) ids(base,customsIds); + if ("draft".equals(q.queue())) base.eq("status","DRAFT"); + base.orderByDesc("CASE WHEN status='DRAFT' THEN 1 ELSE 0 END","created_at","id"); + var page=new Page(q.page(),q.size(),false); + page.setTotal(total); + if (total>0) mapper.selectPage(page,base); + var channels=mapper.selectObjs(new QueryWrapper().select("DISTINCT channel_name") + .eq("tenant_id",customerId).isNotNull("channel_name").ne("channel_name","").orderByAsc("channel_name")) + .stream().map(Object::toString).toList(); + return new Result(new PageResult<>(page.getRecords(),q.page(),q.size(),total, + total/q.size()+(total%q.size()==0?0:1)),counts,channels); + } + + private QueryWrapper base(long customerId, MerchantOrderWorkbenchQuery q, List statusIds) { + var w=new QueryWrapper().eq("tenant_id",customerId); + String direct=switch(q.mainStatus()==null?"":q.mainStatus()) { + case "DRAFT"->"DRAFT";case "CANCELLED"->"CANCELLED";case "COMPLETED"->"COMPLETED"; + case "PENDING_REVIEW"->"SUBMITTED";default->null; + }; + if(direct!=null) w.eq("status",direct); + else if(q.mainStatus()!=null) ids(w.in("status",List.of("APPROVED","ACCEPTED","PROCESSING")),statusIds); + else w.in("status",ACTIVE); + w.eq(q.receivingWarehouseId()!=null,"receiving_warehouse_id",q.receivingWarehouseId()) + .eq(StringUtils.hasText(q.channelName()),"channel_name",q.channelName()) + .eq(StringUtils.hasText(q.destinationType()),"destination_type",q.destinationType()) + .like(StringUtils.hasText(q.trackingNumber()),"express_tracking_no",q.trackingNumber()) + .ge(q.createdFrom()!=null,"created_at",q.createdFrom()) + .le(q.createdTo()!=null,"created_at",q.createdTo()); + if(StringUtils.hasText(q.cargoKeyword())) w.and(n->n.like("inbound_code",q.cargoKeyword()) + .or().apply("EXISTS (SELECT 1 FROM ord_order_cargo cargo WHERE cargo.tenant_id=ord_order.tenant_id " + +"AND cargo.order_id=ord_order.id AND cargo.deleted=0 AND cargo.sku_no LIKE CONCAT('%',{0},'%'))",q.cargoKeyword())); + return w; + } + + private QueryWrapper ids(QueryWrapper query,List values) { + if(values==null) throw new IllegalArgumentException("Order filter identifiers are required"); + return values.isEmpty()?query.apply("1=0"):query.in("id",values); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java new file mode 100644 index 000000000..df79a8f30 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java @@ -0,0 +1,43 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.dto.MerchantOrderWorkbenchQuery; +import com.baiyue.order.dto.MerchantOrderWorkbenchResponse; +import com.baiyue.order.repository.MerchantOrderWorkbenchRepository; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import java.util.Set; + +@Service +public class MerchantOrderWorkbenchService { + @Resource private MerchantOrderWorkbenchRepository repository; + @Resource private FirstlegUnifiedOrderClient firstleg; + @Resource private UnifiedOrderQueryService unified; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private static final Set DIRECT=Set.of("DRAFT","PENDING_REVIEW","COMPLETED","CANCELLED"); + + public MerchantOrderWorkbenchResponse page(long customerId,MerchantOrderWorkbenchQuery query) { + if(query.createdFrom()!=null&&query.createdTo()!=null&&query.createdFrom().isAfter(query.createdTo())) + throw new BizException(ResultCode.BAD_REQUEST,"创建时间起止范围不合法"); + String status=query.mainStatus()==null||DIRECT.contains(query.mainStatus())?null:query.mainStatus(); + FirstlegUnifiedOrderClient.WorkbenchFilters filters; + try { + var response=firstleg.workbenchFilters(internalToken, + new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(customerId,status)); + if(response==null||!response.successful()||response.data()==null) throw new IllegalStateException(); + filters=response.data(); + if(filters.feeOrderIds()==null||filters.customsOrderIds()==null + ||status!=null&&filters.matchingStatusOrderIds()==null) throw new IllegalStateException(); + } catch(RuntimeException failure) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单待办查询暂时不可用,请重试"); + } + var result=repository.read(customerId,query,filters.matchingStatusOrderIds(),filters.feeOrderIds(),filters.customsOrderIds()); + var source=result.page(); + return new MerchantOrderWorkbenchResponse(new PageResult<>(unified.merchantWorkbenchRows(customerId,source.records()), + source.page(),source.size(),source.total(),source.pages()),result.queues(),result.channelNames()); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index c266bc268..9722b842f 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -74,6 +74,15 @@ public class UnifiedOrderQueryService { order.getInternalRemark(), childCounts, cargoSummaries.get(order.getId()), null)).toList(); } + public List merchantWorkbenchRows(long tenantId,List selected) { + List activeIds=selected.stream().filter(order->order.getStatus()!=OrderStatus.DRAFT + &&order.getStatus()!=OrderStatus.CANCELLED).map(OrderEntity::getId).toList(); + var summaries=summaries(tenantId,activeIds); + var cargo=cargoSummaries(tenantId,selected); + var children=childCounts(selected); + return selected.stream().map(order->row(order,summaries.get(order.getId()),null,children,cargo.get(order.getId()))).toList(); + } + private PageResult page(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { validate(query); @@ -116,8 +125,9 @@ public class UnifiedOrderQueryService { OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargoInheritedByOrder(tenantId, orderId), orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), orders.findHistory(tenantId, orderId)); - FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED - ? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId); + FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.CANCELLED + ? null : order.getStatus() == OrderStatus.SUBMITTED + ? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId); String latestNote = order.getInternalRemark(); String creatorName = orderDetail.statusHistory().stream() .filter(item -> "CREATE".equals(item.getAction())) @@ -439,6 +449,7 @@ public class UnifiedOrderQueryService { private String orderMainStatus(OrderStatus status) { if (status == null) return null; + if (status == OrderStatus.DRAFT || status == OrderStatus.CANCELLED) return status.name(); return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW" : status == OrderStatus.COMPLETED ? "COMPLETED" : ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 30a81fd82..e1bcba11b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,49 @@ info: security: - bearerAuth: [] paths: + /api/merchant/v1/orders/workbench: + get: + summary: 客户订单工作台分页和筛选后待办数量 + description: >- + 仅按登录客户查询,权限merchant:order:list。草稿优先,再按创建时间和订单ID降序稳定分页。 + 入仓码/SKU采用或匹配;所有条件先筛选再计数。四类数量共用当前筛选,不从当前页行数计算。 + 默认包含草稿和有效正式单;履约子状态以Firstleg为准,待审核和已完成以Order为准。 + security: [{bearerAuth: []}] + parameters: + - {name: cargoKeyword, in: query, schema: {type: string, maxLength: 64}} + - {name: receivingWarehouseId, in: query, schema: {type: integer, format: int64}} + - {name: channelName, in: query, schema: {type: string, maxLength: 128}} + - {name: destinationType, in: query, schema: {type: string, enum: [FBA, OVERSEAS, PRIVATE]}} + - {name: trackingNumber, in: query, schema: {type: string, maxLength: 128}} + - {name: mainStatus, in: query, schema: {type: string, enum: [DRAFT, PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, CANCELLED]}} + - {name: createdFrom, in: query, schema: {type: string, format: date-time}} + - {name: createdTo, in: query, schema: {type: string, format: date-time}} + - {name: queue, in: query, schema: {type: string, enum: [all, fee, customs, draft], default: all}} + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 10}} + responses: + '200': + description: 成功或业务错误;后端依赖不可用时返回明确错误,不返回局部成功数据 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + required: [page, queues, channelNames] + properties: + page: {$ref: '#/components/schemas/UnifiedOrderPage'} + queues: + type: object + required: [all, fee, customs, draft] + properties: + all: {type: integer, format: int64, minimum: 0} + fee: {type: integer, format: int64, minimum: 0} + customs: {type: integer, format: int64, minimum: 0} + draft: {type: integer, format: int64, minimum: 0} + channelNames: {type: array, items: {type: string}, description: 当前客户完整渠道筛选项,不限于当前页} /internal/order/v1/fulfillment-completions: post: security: [] @@ -1041,7 +1084,7 @@ components: asOf: {type: string, format: date-time} UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED] + enum: [DRAFT, PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, CANCELLED] description: >- 统一订单八态产品主状态。ONE_CLICK_DROPSHIP 测量计费完成后进入 WAIT_PALLETIZE,封托完成后进入 WAIT_SPLIT; STANDARD 保持既有流程,INBOUNDED 仅兼容存量已入库记录。 diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java new file mode 100644 index 000000000..fc2cd3196 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java @@ -0,0 +1,70 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.MerchantOrderWorkbenchQuery; +import com.baiyue.order.service.MerchantOrderWorkbenchService; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.Set; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.core.MethodParameter; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MerchantOrderWorkbenchControllerTest { + @Test void takesCustomerOnlyFromAuthenticatedMerchantAndKeepsListPermission() throws Exception { + var service=mock(MerchantOrderWorkbenchService.class); + var controller=new MerchantUnifiedOrderController(); + ReflectionTestUtils.setField(controller,"workbench",service); + var query=new MerchantOrderWorkbenchQuery(null,null,null,null,null,null,null,null,null,0,0); + var user=new AuthenticatedUser(1L,11L,null,1L,"customer","MERCHANT","WEB",Set.of("merchant:order:list")); + controller.workbench(user,query); + verify(service).page(11,query); + clearInvocations(service); + for(var wrong : new AuthenticatedUser[]{null, + new AuthenticatedUser(1L,0L,null,1L,"platform","PLATFORM","WEB",Set.of()), + new AuthenticatedUser(1L,0L,null,1L,"customer","MERCHANT","WEB",Set.of())}) { + assertThatThrownBy(()->controller.workbench(wrong,query)).isInstanceOf(BizException.class); + } + verifyNoInteractions(service); + assertThat(MerchantUnifiedOrderController.class.getMethod("workbench",AuthenticatedUser.class,MerchantOrderWorkbenchQuery.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('merchant:order:list')"); + } + @Test void validatesPageBoundsAndClosedFilterEnums() { + try(var factory=Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + var invalid=new MerchantOrderWorkbenchQuery(null,null,null,"UNKNOWN",null,"UNKNOWN",null,null,"UNKNOWN",-1,201); + assertThat(validator.validate(invalid)).extracting(v->v.getPropertyPath().toString()) + .contains("destinationType","mainStatus","queue","page","size"); + var defaults=new MerchantOrderWorkbenchQuery(null,null,null,null,null,"DRAFT",null,null,null,0,0); + assertThat(validator.validate(defaults)).isEmpty(); + assertThat(defaults.page()).isEqualTo(1);assertThat(defaults.size()).isEqualTo(10); + } + } + @Test void bindsBrowserIsoDatesAndIgnoresSpoofedCustomerParameters() throws Exception { + var service=mock(MerchantOrderWorkbenchService.class); + var controller=new MerchantUnifiedOrderController(); + ReflectionTestUtils.setField(controller,"workbench",service); + var user=new AuthenticatedUser(1L,11L,null,1L,"customer","MERCHANT","WEB",Set.of("merchant:order:list")); + var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + public boolean supportsParameter(MethodParameter p) { return p.getParameterType()==AuthenticatedUser.class; } + public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory f) { return user; } + }).build(); + mvc.perform(get("/api/merchant/v1/orders/workbench").param("page","21").param("size","10") + .param("createdFrom","2026-10-01T00:00:00").param("createdTo","2026-10-03T23:59:59.999") + .param("tenantId","999").param("customerId","999")).andExpect(status().isOk()); + var captured=org.mockito.ArgumentCaptor.forClass(MerchantOrderWorkbenchQuery.class); + verify(service).page(eq(11L),captured.capture()); + assertThat(captured.getValue().page()).isEqualTo(21); + assertThat(captured.getValue().createdTo()).isEqualTo(java.time.LocalDateTime.of(2026,10,3,23,59,59,999000000)); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 07a5ecf2d..fa625f4d0 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -50,8 +50,8 @@ class UnifiedOrderApiContractTest { Map schemas = stringMap(components.get("schemas")); Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + "DRAFT", "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED", "COMPLETED", "CANCELLED"); assertThat(responseStatus.get("description").toString()) .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); Schema queryAnnotation = UnifiedOrderQuery.class.getDeclaredField("mainStatus") diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java new file mode 100644 index 000000000..edce11769 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java @@ -0,0 +1,110 @@ +package com.baiyue.order.repository; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.dto.MerchantOrderWorkbenchQuery; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +/** Real mapper + pagination interceptor: customer filters apply before COUNT and LIMIT. */ +class MerchantOrderWorkbenchRepositoryTest { + private MerchantOrderWorkbenchRepository repository; + private JdbcTemplate jdbc; + private OrderMapper mapper; + @BeforeEach + void setup() throws Exception { + var dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:workbench-page-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(dataSource); + var columns = new ArrayList(); + for (Class type = OrderEntity.class; type != Object.class; type = type.getSuperclass()) { + for (var field : type.getDeclaredFields()) { + var annotation = field.getAnnotation(TableField.class); + if (Modifier.isStatic(field.getModifiers()) || annotation != null && !annotation.exist()) continue; + String name = field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + Class valueType = field.getType(); + String sqlType = valueType == Long.class || valueType == long.class ? "BIGINT" + : valueType == Integer.class || valueType == int.class || valueType == Boolean.class || valueType == boolean.class ? "INT" + : valueType == java.math.BigDecimal.class ? "DECIMAL(20,6)" + : valueType == java.time.LocalDateTime.class ? "TIMESTAMP" + : valueType == java.time.LocalDate.class ? "DATE" : "VARCHAR(2000)"; + columns.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" + : name.equals("deleted") || name.equals("version") ? " DEFAULT 0" + : name.equals("created_at") ? " DEFAULT CURRENT_TIMESTAMP" : "")); + } + } + jdbc.execute("CREATE TABLE ord_order (" + String.join(",", columns) + ")"); + jdbc.execute("CREATE TABLE ord_order_cargo(id BIGINT,tenant_id BIGINT,order_id BIGINT,deleted INT,sku_no VARCHAR(128))"); + for(int id=1;id<=215;id++) jdbc.update("INSERT INTO ord_order(id,tenant_id,status,inbound_code,channel_name,destination_type,receiving_warehouse_id,express_tracking_no,created_at) VALUES(?,?,?,?,?,?,?,?,?)", + id,id>213?22:11,id<=3?"DRAFT":"APPROVED","IN-"+id,id%2==0?"SEA":"AIR","FBA",10,"TRACK-"+id,java.time.LocalDateTime.of(2026,10,2,12,0)); + jdbc.update("UPDATE ord_order SET deleted=1 WHERE id=213"); + jdbc.update("INSERT INTO ord_order_cargo VALUES(1,11,210,0,'SPECIAL'),(2,22,211,0,'SPECIAL')"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); config.addMapper(OrderMapper.class); + var pagination = new MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(config); factory.setPlugins(pagination); + var session = new SqlSessionTemplate(factory.getObject()); + repository = new MerchantOrderWorkbenchRepository(); + mapper = session.getMapper(OrderMapper.class); + ReflectionTestUtils.setField(repository, "mapper", mapper); + } + private MerchantOrderWorkbenchQuery query(String keyword,String queue,long page) { + return new MerchantOrderWorkbenchQuery(keyword,null,null,null,null,null,null,null,queue,page,10); + } + @Test void readsBeyondTheOldTwoHundredLimitWithStableDraftPrefixAndCorrectTotals() { + var first=repository.read(11,query(null,"all",1),null,List.of(210L),List.of(211L)); + assertThat(first.page().total()).isEqualTo(212); + assertThat(first.page().records().subList(0,3)).extracting(OrderEntity::getId).containsExactly(3L,2L,1L); + var last=repository.read(11,query(null,"all",22),null,List.of(210L),List.of(211L)); + assertThat(last.page().records()).extracting(OrderEntity::getId).containsExactly(5L,4L); + assertThat(last.page().pages()).isEqualTo(22); + assertThat(last.queues().all()).isEqualTo(212); + assertThat(last.queues().draft()).isEqualTo(3); + assertThat(last.queues().fee()).isEqualTo(1); + assertThat(last.queues().customs()).isEqualTo(1); + assertThat(last.channelNames()).containsExactly("AIR","SEA"); + assertThat(repository.read(11,query(null,"all",23),null,List.of(),List.of()).page().records()).isEmpty(); + } + @Test void filtersBeforeCountingAndPagingAndNeverUsesForeignSkuOrQueueIds() { + var sku=repository.read(11,query("SPECIAL","all",1),null,List.of(210L,214L),List.of(215L)); + assertThat(sku.page().total()).isEqualTo(1); + assertThat(sku.page().records()).extracting(OrderEntity::getId).containsExactly(210L); + assertThat(sku.queues().fee()).isEqualTo(1);assertThat(sku.queues().customs()).isZero(); + var fee=repository.read(11,query(null,"fee",1),null,List.of(210L,214L),List.of()); + assertThat(fee.page().records()).extracting(OrderEntity::getId).containsExactly(210L); + assertThat(repository.read(11,query(null,"fee",1),null,List.of(),List.of()).page().records()).isEmpty(); + var drafts=repository.read(11,query(null,"draft",1),null,List.of(),List.of()); + assertThat(drafts.page().total()).isEqualTo(3); + } + @Test void combinesWarehouseChannelDestinationTrackingDatesAndStatusBeforePaging() { + var from=java.time.LocalDateTime.of(2026,10,2,0,0); + var to=from.plusDays(1).minusNanos(1); + var q=new MerchantOrderWorkbenchQuery("IN-210",10L,"SEA","FBA","TRACK-210","LOADED",from,to,"all",1,10); + assertThat(repository.read(11,q,List.of(210L,214L),List.of(),List.of()).page().records()) + .extracting(OrderEntity::getId).containsExactly(210L); + assertThat(repository.read(11,q,List.of(),List.of(),List.of()).page().total()).isZero(); + jdbc.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=210"); + assertThat(repository.read(11,q,List.of(210L),List.of(),List.of()).page().total()).isZero(); + var pending=new MerchantOrderWorkbenchQuery(null,null,null,null,null,"PENDING_REVIEW",null,null,"all",1,10); + assertThat(repository.read(11,pending,null,List.of(),List.of()).page().records()).extracting(OrderEntity::getId).containsExactly(210L); + var outside=new MerchantOrderWorkbenchQuery(null,null,null,null,null,null,to.plusDays(1),null,"all",1,10); + assertThat(repository.read(11,outside,null,List.of(),List.of()).page().total()).isZero(); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java new file mode 100644 index 000000000..68f65338f --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.dto.MerchantOrderWorkbenchQuery; +import com.baiyue.order.dto.MerchantOrderWorkbenchResponse.Counts; +import com.baiyue.order.repository.MerchantOrderWorkbenchRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class MerchantOrderWorkbenchServiceTest { + private final FirstlegUnifiedOrderClient firstleg = mock(FirstlegUnifiedOrderClient.class); + private final MerchantOrderWorkbenchRepository repository = mock(MerchantOrderWorkbenchRepository.class); + private final UnifiedOrderQueryService unified = mock(UnifiedOrderQueryService.class); + private final MerchantOrderWorkbenchService service = new MerchantOrderWorkbenchService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "firstleg", firstleg); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "unified", unified); + ReflectionTestUtils.setField(service, "internalToken", "test-service-token"); + } + private MerchantOrderWorkbenchQuery query(String status) { + return new MerchantOrderWorkbenchQuery(null,null,null,null,null,status,null,null,"all",22,10); + } + @Test void combinesScopedFiltersWithDatabasePageWithoutReplacingTotals() { + var q = query("LOADED"); + var filters = new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(1L),List.of(2L),List.of(3L)); + when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(filters)); + when(repository.read(11,q,filters.matchingStatusOrderIds(),filters.feeOrderIds(),filters.customsOrderIds())) + .thenReturn(new MerchantOrderWorkbenchRepository.Result(new PageResult<>(List.of(),22,10,210,21),new Counts(210,2,1,0),List.of("SEA"))); + when(unified.merchantWorkbenchRows(11,List.of())).thenReturn(List.of()); + var result = service.page(11,q); + assertThat(result.page().total()).isEqualTo(210); + assertThat(result.page().page()).isEqualTo(22); + assertThat(result.queues().fee()).isEqualTo(2); + verify(firstleg).workbenchFilters("test-service-token",new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(11,"LOADED")); + verify(unified).merchantWorkbenchRows(11,List.of()); + } + @Test void unavailableOrIncompleteFulfillmentFiltersCannotProduceMisleadingEmptyQueues() { + for(var filters : List.of( + new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of()), + new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),null,List.of()), + new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),List.of(),null))) { + when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(filters)); + assertThatThrownBy(()->service.page(11,query("LOADED"))).isInstanceOf(BizException.class).hasMessageContaining("暂时不可用"); + } + when(firstleg.workbenchFilters(anyString(),any())).thenThrow(new IllegalStateException("unavailable")); + assertThatThrownBy(()->service.page(11,query(null))).isInstanceOf(BizException.class); + verifyNoInteractions(repository,unified); + } + @Test void invalidDateRangeFailsBeforeAnyCrossServiceRead() { + var now=LocalDateTime.of(2026,10,3,0,0); + var q=new MerchantOrderWorkbenchQuery(null,null,null,null,null,null,now,now.minusDays(1),null,0,0); + assertThatThrownBy(()->service.page(11,q)).isInstanceOf(BizException.class).hasMessageContaining("时间"); + verifyNoInteractions(firstleg,repository,unified); + } +} -- 2.54.0 From dd52bfd04327d6224756e0c2f978d67f96b55042 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 15:00:12 +0800 Subject: [PATCH 099/176] test(fleet): verify operations from immutable current-run evidence --- scripts/README.md | 2 + .../fleet-quotes-operations.mjs | 68 +++++++++++++++ ...leet-operations-test-env-contract.test.mjs | 67 +++++++++++++++ scripts/fleet-operations-test-env.mjs | 84 +++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 scripts/fleet-operations-test-env-contract.test.mjs create mode 100644 scripts/fleet-operations-test-env.mjs diff --git a/scripts/README.md b/scripts/README.md index d20604286..2ec1b1597 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports//handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。 + `fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 `fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=a6fd0ba3`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index f710c0ee3..fca72635d 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -1,4 +1,66 @@ import { createHash } from 'node:crypto' +import { expectedAllocations } from './fleet-trip-cost-acceptance.mjs' + +/** C's signed-by-hash handover artifact is the only producer accepted by the standalone reader. */ +export function operationsFixtureFromHandover(bytes, { sha256, marker, runId }) { + const fail = () => { throw new Error('Current C fixture, original DEPART or verified independent cost shares do not match') } + if (!/^[a-f0-9]{64}$/.test(sha256 || '') || createHash('sha256').update(bytes).digest('hex') !== sha256 || !marker || !runId) fail() + const f = JSON.parse(bytes.toString('utf8')), history = f.immutableDeparture, snapshot = history?.snapshot, load = snapshot?.confirmedLoad, c = f.costs + if (f.marker !== marker || f.runId !== runId || f.status !== 'DEPART_AND_HANDOVER_VERIFIED' || !f.handoverNo + || !f.tripNo || !f.warehouseId || history?.action !== 'DEPART' || snapshot?.trip?.tripNo !== f.tripNo + || String(snapshot.trip.warehouseId) !== String(f.warehouseId) || !snapshot.trip.remark?.includes(marker) + || f.departure?.tripNo !== f.tripNo || f.departure.status !== 'IN_TRANSIT' || !Number.isFinite(Date.parse(f.departure.departedAt)) + || snapshot.trip.departedAt !== f.departure.departedAt || !load?.manifestHash + || c?.status !== 'VERIFIED' || c.marker !== marker || c.tripNo !== f.tripNo + || c.expected?.finalCny !== '100.03' || c.expected?.usd !== '13.07' || c.expected?.allocationMethod !== 'SAME_ROUTE_PALLET_SPACES') fail() + const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0 + if (!Array.isArray(load.pallets) || !load.pallets.length || !Array.isArray(load.cargo) || !load.cargo.length || !Array.isArray(c.shares) || !c.shares.length) fail() + const pallets = new Map() + for (const p of load.pallets) { if (!p.warehousePalletId || pallets.has(String(p.warehousePalletId)) || !positive(p.palletSpaces)) fail(); pallets.set(String(p.warehousePalletId), Number(p.palletSpaces)) } + const seen = new Set(), shipped = new Map() + for (const row of load.cargo) { + const item = `${row.dispatchNo}:${row.dispatchItemId}`, key = `${row.dispatchNo}:${row.customerId}:${row.warehousePalletId}` + if (!row.dispatchNo || !row.dispatchItemId || !row.customerId || seen.has(item) || !pallets.has(String(row.warehousePalletId)) || !positive(row.packages)) fail() + seen.add(item); shipped.set(key, (shipped.get(key) || 0) + Number(row.packages)) + } + const bookings = new Map(), external = new Map(), money = new Map() + for (const b of snapshot.externalBookings || []) { + if (['DRAFT', 'CANCELLED'].includes(b.status)) continue + if (b.status !== 'BOUND' || !b.bookingNo || bookings.has(b.bookingNo) || !b.dispatchNo || !b.receipt?.source?.pallets?.length || !['CNY', 'USD'].includes(b.cargo?.currency)) fail() + bookings.set(b.bookingNo, b) + let count = 0 + for (const p of b.receipt.source.pallets) { if (!positive(p.packages)) fail(); const key = `${b.dispatchNo}:${b.customerId}:${p.warehousePalletId}`; external.set(key, (external.get(key) || 0) + Number(p.packages)); count += Number(p.packages) } + if (count !== Number(b.receipt.source.receivedPackages)) fail() + const amount = number(b.agreedAmount); money.set(b.cargo.currency, (money.get(b.cargo.currency) || 0) + Math.round(amount * 100)) + } + for (const [key, value] of external) if (value > (shipped.get(key) || 0)) fail() + const sharePallets = new Set(), businesses = new Set() + for (const share of c.shares) { + if (!share.businessNo || businesses.has(share.businessNo) || !['OWN', 'EXTERNAL'].includes(share.sourceType) || !share.warehousePalletIds?.length) fail() + businesses.add(share.businessNo) + let spaces = 0 + for (const id of share.warehousePalletIds) { + if (!pallets.has(String(id)) || sharePallets.has(String(id)) || !load.cargo.some(row => row.dispatchNo === share.dispatchNo && String(row.warehousePalletId) === String(id))) fail() + sharePallets.add(String(id)); spaces += pallets.get(String(id)) + } + if (spaces !== share.palletSpaces) fail() + if (share.sourceType === 'EXTERNAL') { + const b = bookings.get(share.businessNo) + if (!b || b.dispatchNo !== share.dispatchNo || b.receipt.source.pallets.some(p => !share.warehousePalletIds.map(String).includes(String(p.warehousePalletId)))) fail() + } else if (share.businessNo !== share.dispatchNo || [...bookings.values()].some(b => b.dispatchNo === share.dispatchNo)) fail() + } + if (sharePallets.size !== pallets.size || !c.shares.some(s => s.sourceType === 'OWN') || !c.shares.some(s => s.sourceType === 'EXTERNAL')) fail() + const total = [...shipped.values()].reduce((a, b) => a + b, 0), ext = [...external.values()].reduce((a, b) => a + b, 0) + if (total !== Number(f.departure.shippedPackages) || ext <= 0 || ext >= total) fail() + const costs = [['CNY', '100.03'], ['USD', '13.07']].map(([currency, amount]) => { + const allocations = expectedAllocations(amount, c.shares) + const sum = type => Number(allocations.filter(row => row.sourceType === type).reduce((n, row) => n + BigInt(row.cents), 0n)) / 100 + return { currency, directCost: Number(amount), ownCost: sum('OWN'), externalCost: sum('EXTERNAL') } + }) + return { marker, stage: 'CURRENT_RUN_DEPARTED', tripNo: f.tripNo, warehouseId: String(f.warehouseId), departedAt: f.departure.departedAt, departureVerified: true, + immutableDeparture: history, expected: { physicalPallets: pallets.size, palletSpaces: [...pallets.values()].reduce((a, b) => a + b, 0), ownPackages: total - ext, externalPackages: ext, + externalAgreedAmounts: [...money].map(([currency, amount]) => ({ currency, amount: amount / 100 })), costs } } +} const MODULE = 'fleet-quotes-operations' const QUOTES = '/api/platform/v1/last-leg/fleet/quotes' @@ -10,6 +72,7 @@ const encode = encodeURIComponent const query = values => new URLSearchParams(Object.entries(values).filter(([, value]) => value != null).map(([key, value]) => [key, String(value)])).toString() const version = value => BigInt(id(value)) const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value function number(value) { if (!['number', 'string'].includes(typeof value) || !/^\d+(?:\.\d{1,2})?$/.test(String(value)) || !Number.isFinite(Number(value))) throw new Error('Missing or invalid numeric fact; never convert unknown to zero') return Number(value) @@ -133,6 +196,11 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames let fixture, from, to, report if (scope === 'OPERATIONS_ONLY') { fixture = requireCurrentFixture(context) + if (fixture.immutableDeparture) { + const history = await command('GET', `/api/platform/v1/last-leg/fleet/trips/${encode(fixture.tripNo)}/history?page=1&size=200`, undefined, '核对输入报告的原DEPART不可变快照') + const departures = history.records?.filter(row => row.action === 'DEPART') || [] + check('当前历史完整且原DEPART与SHA绑定报告一致', null, Number(history.total) === history.records?.length && departures.length === 1 && same(canonical(departures[0]), canonical(fixture.immutableDeparture))) + } proof.tripNo = fixture.tripNo const trip = await command('GET', `/api/platform/v1/last-leg/fleet/trips/${encode(fixture.tripNo)}`, undefined, '核对本轮真实发车车次') check('车次属于当前场景并有真实发车时间', null, trip.trip?.tripNo === fixture.tripNo && id(trip.trip.warehouseId) === id(fixture.warehouseId) diff --git a/scripts/fleet-operations-test-env-contract.test.mjs b/scripts/fleet-operations-test-env-contract.test.mjs new file mode 100644 index 000000000..53858bf45 --- /dev/null +++ b/scripts/fleet-operations-test-env-contract.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { operationsFixtureFromHandover } from './admin-test-env-modules/fleet-quotes-operations.mjs' +import { parseOperationsArguments, restrictOperationsTransport, runOperationsSession } from './fleet-operations-test-env.mjs' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' + +function sample() { + const marker = 'CURRENT', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' + return { marker, runId: 'current-run', tripNo, warehouseId: '92', status: 'DEPART_AND_HANDOVER_VERIFIED', handoverNo: 'HO-CURRENT', + departure: { tripNo, status: 'IN_TRANSIT', departedAt, shippedPackages: 14 }, + immutableDeparture: { action: 'DEPART', recordVersion: '5', snapshot: { + trip: { tripNo, warehouseId: '92', departedAt, remark: marker }, confirmedLoad: { manifestHash: 'original-load', + pallets: [{ warehousePalletId: '1', palletSpaces: 1 }, { warehousePalletId: '2', palletSpaces: 1 }], + cargo: [{ dispatchNo: 'DP-OWN', dispatchItemId: '1', customerId: '9', warehousePalletId: '1', packages: 10 }, { dispatchNo: 'DP-EXT', dispatchItemId: '2', customerId: '9', warehousePalletId: '2', packages: 4 }] }, + externalBookings: [{ bookingNo: 'FB-EXT', status: 'BOUND', dispatchNo: 'DP-EXT', customerId: '9', agreedAmount: '30.00', cargo: { currency: 'CNY' }, receipt: { source: { receivedPackages: 4, pallets: [{ warehousePalletId: '2', packages: 4 }] } } }] } }, + costs: { marker, tripNo, status: 'VERIFIED', expected: { finalCny: '100.03', usd: '13.07', allocationMethod: 'SAME_ROUTE_PALLET_SPACES' }, shares: [ + { businessNo: 'DP-OWN', sourceType: 'OWN', dispatchNo: 'DP-OWN', palletSpaces: 1, warehousePalletIds: ['1'] }, + { businessNo: 'FB-EXT', sourceType: 'EXTERNAL', dispatchNo: 'DP-EXT', palletSpaces: 1, warehousePalletIds: ['2'] }] } } +} +function convert(value, overrides = {}) { + const bytes = Buffer.from(JSON.stringify(value)) + return operationsFixtureFromHandover(bytes, { sha256: createHash('sha256').update(bytes).digest('hex'), marker: 'CURRENT', runId: 'current-run', ...overrides }) +} +test('adapts actual C artifact with independent physical cargo and exact cent currency shares', () => { + const f = convert(sample()) + assert.deepEqual(f.expected, { physicalPallets: 2, palletSpaces: 2, ownPackages: 10, externalPackages: 4, + externalAgreedAmounts: [{ currency: 'CNY', amount: 30 }], costs: [ + { currency: 'CNY', directCost: 100.03, ownCost: 50.02, externalCost: 50.01 }, + { currency: 'USD', directCost: 13.07, ownCost: 6.54, externalCost: 6.53 }] }) + assert.equal(f.stage, 'CURRENT_RUN_DEPARTED'); assert.equal(f.departureVerified, true) +}) +for (const [name, mutate] of [ + ['stale marker', f => { f.marker = 'OLD' }], ['stale run', f => { f.runId = 'old' }], + ['unverified costs', f => { f.costs.status = 'CHECKING' }], ['wrong currency amount', f => { f.costs.expected.usd = '0' }], + ['not departed', f => { f.departure.status = 'PLANNED' }], ['replaced history', f => { f.immutableDeparture.action = 'HANDOVER' }], + ['duplicate physical pallet', f => { f.immutableDeparture.snapshot.confirmedLoad.pallets.push(f.immutableDeparture.snapshot.confirmedLoad.pallets[0]) }], + ['external over-attribution', f => { f.immutableDeparture.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 99 }], + ['wrong shares', f => { f.costs.shares[1].warehousePalletIds = ['1'] }], ['wrong departure total', f => { f.departure.shippedPackages = 15 }], +]) test(`rejects ${name} before any HTTP`, () => { const f = sample(); mutate(f); assert.throws(() => convert(f)) }) +test('rejects mismatched file hash even when its contents look valid', () => assert.throws(() => convert(sample(), { sha256: '0'.repeat(64) }))) +test('requires explicit report/hash/current marker/run and TEST readiness; denies write and resume flags', () => { + const args = ['--ready-commit=a6fd0ba3', '--fixture=reports/fleet-current-run/handover-fixture.json', `--fixture-sha256=${'a'.repeat(64)}`, '--marker=CURRENT', '--run-id=current-run'] + const runtime = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] + assert.equal(parseOperationsArguments(args, runtime).runId, 'current-run') + for (const bad of [args.slice(1), [...args, '--write-measured=true'], [...args, '--resume=true'], args.map(a => a.startsWith('--fixture=') ? '--fixture=../old/handover-fixture.json' : a)]) assert.throws(() => parseOperationsArguments(bad, runtime)) +}) +test('transport permits only current-trip/statistics reads plus independent session login/logout', async () => { + const context = new AcceptanceRun({ environment: 'test' }), calls = [] + for (const name of ['request', 'requestWithBody', 'requestBinary']) context[name] = async (...args) => { calls.push(args); return {} } + restrictOperationsTransport(context, 'TR-CURRENT') + await context.request('GET', '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') + for (const [method, path] of [['PUT', '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT'], ['POST', '/api/platform/v1/last-leg/fleet/trips'], ['GET', '/api/platform/v1/last-leg/fleet/trips/TR-OLD'], ['GET', '/api/platform/v1/iam/tenants']]) assert.throws(() => context.request(method, path)) + assert.equal(calls.length, 1) +}) +test('new login and logout surround a rejected live immutable snapshot without business writes', async () => { + const context = new AcceptanceRun({ environment: 'test', username: 'offline', password: 'offline', quiet: true }), calls = [] + context.request = async (method, path) => { + calls.push([method, path]) + return { http: 200, body: { code: '200', data: path.endsWith('/login') ? { accessToken: 'offline-access', refreshToken: 'offline-refresh' } : path.includes('/history') ? { total: 0, records: [] } : null }, requestId: 'offline', traceId: 'offline' } + } + const report = await runOperationsSession(context, convert(sample())) + assert.equal(report.success, false); assert.equal(report.loggedOut, true); assert.equal(report.measuredStatus, 'NOT_TESTED') + assert.deepEqual(calls.map(c => c[0]), ['POST', 'GET', 'POST']) + assert.equal(calls[0][1], '/api/auth/login'); assert.equal(calls.at(-1)[1], '/api/auth/logout') + assert.equal(context.token, '') +}) diff --git a/scripts/fleet-operations-test-env.mjs b/scripts/fleet-operations-test-env.mjs new file mode 100644 index 000000000..153402ce6 --- /dev/null +++ b/scripts/fleet-operations-test-env.mjs @@ -0,0 +1,84 @@ +import { readFile, mkdir, open } from 'node:fs/promises' +import { resolve, dirname, relative, isAbsolute } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { requireTestProfile, installQuoteJournal, READY_COMMIT } from './fleet-quotes-test-env.mjs' +import { operationsFixtureFromHandover, verifyOperations } from './admin-test-env-modules/fleet-quotes-operations.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export function parseOperationsArguments(args, execArgs) { + const allowed = ['ready-commit', 'fixture', 'fixture-sha256', 'marker', 'run-id'], values = {} + for (const arg of args) { + const match = /^--([^=]+)=(.+)$/.exec(arg) + if (!match || !allowed.includes(match[1]) || values[match[1]]) throw new Error('Explicit unique operations arguments required; no resume or write mode') + values[match[1]] = match[2] + } + if (allowed.some(name => !values[name]) || !/^[a-f0-9]{64}$/.test(values['fixture-sha256']) || !/^[A-Za-z0-9_-]{1,80}$/.test(values.marker) || !/^[A-Za-z0-9_-]{1,48}$/.test(values['run-id'])) throw new Error('Missing current-run identity or SHA256') + const envFile = requireTestProfile([`--ready-commit=${values['ready-commit']}`], execArgs) + const fixturePath = resolve(root, values.fixture), rel = relative(resolve(root, 'reports'), fixturePath) + if (isAbsolute(rel) || rel.startsWith('..') || !/^[^/\\]+[/\\]handover-fixture\.json$/.test(rel)) throw new Error('Only an explicit C report under this TEST repository reports directory is accepted') + return { envFile, fixturePath, sha256: values['fixture-sha256'], marker: values.marker, runId: values['run-id'] } +} + +export async function runOperationsSession(context, fixture) { + if (context.options?.environment !== 'test' || context.token || context.refreshToken || Object.keys(context.createdResources).length) throw new Error('Fresh independent TEST session required') + context.marker = fixture.marker + context.context.fleetQuotesOperationsFixture = fixture + const successful = r => r?.http === 200 && String(r.body?.code) === '200' + let failure = null, loggedOut = false + try { + const login = await context.request('POST', '/api/auth/login', { loginId: context.options.username, password: context.options.password }, '') + if (!successful(login) || !login.body.data?.accessToken || !login.body.data?.refreshToken) throw new Error('Independent TEST login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken + await verifyOperations(context, { writeMeasured: false }) + } catch (error) { failure = error.message } + finally { + try { + if (context.token) loggedOut = successful(await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken })) + } catch { failure ||= 'Logout outcome unknown' } + context.token = ''; context.refreshToken = '' + } + return { marker: fixture.marker, scope: 'OPERATIONS_ONLY', success: !failure && loggedOut && context.steps.every(s => s.passed), failure, loggedOut, + measuredStatus: 'NOT_TESTED', financeStatus: 'NOT_TESTED', steps: context.steps, createdResources: context.createdResources } +} + +export function restrictOperationsTransport(context, tripNo) { + for (const name of ['request', 'requestWithBody', 'requestBinary']) { + const original = context[name].bind(context) + context[name] = (method, path, ...args) => { + const trip = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}` + const read = method === 'GET' && (path === trip || path.startsWith(`${trip}/history?`) || /^\/api\/platform\/v1\/last-leg\/fleet\/operations(?:\/export)?\?/.test(path)) + const auth = name === 'request' && method === 'POST' && ['/api/auth/login', '/api/auth/logout'].includes(path) + if (!read && !auth) throw new Error('Operations reader forbids business writes and unrelated reads') + return original(method, path, ...args) + } + } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const config = parseOperationsArguments(args, execArgs) + if (!root.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('Registered TEST workspace required') + const fixture = operationsFixtureFromHandover(await readFile(config.fixturePath), config) + const env = parseEnv(await readFile(config.envFile, 'utf8')), base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD || !['http:', 'https:'].includes(base.protocol) + || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw new Error('Explicit unified TEST configuration required') + const directory = resolve(root, 'reports', `fleet-operations-${config.runId}`) + await mkdir(directory, { recursive: true }) + const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, quiet: true }) + restrictOperationsTransport(context, fixture.tripNo) + const journal = await installQuoteJournal(context, directory, { secrets: [env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD], persist: async (name, evidence) => { + const file = await open(resolve(directory, name), 'wx', 0o600) + try { + await file.writeFile(JSON.stringify({ ...evidence, marker: config.marker, scope: 'OPERATIONS_ONLY', expectedCommit: READY_COMMIT, + input: { fixturePath: config.fixturePath, sha256: config.sha256, runId: config.runId }, expected: fixture.expected }, null, 2)); await file.sync() + } finally { await file.close() } + } }) + const report = await runOperationsSession(context, fixture) + await journal.checkpoint('final', report) + return { success: report.success && !journal.unknown(), directory } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.success ? 0 : 1 }) + .catch(() => { console.error('运营只读验收未启动或中止,请核对本轮C报告、SHA及持久检查点;未自动重跑。'); process.exitCode = 1 }) +} -- 2.54.0 From bf8a985f22d881f59fa2a1a6ccdb0d9a5277fa61 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 15:46:42 +0800 Subject: [PATCH 100/176] test(order): add readonly merchant workbench pagination acceptance --- scripts/README.md | 2 + scripts/client-workbench-test-env.mjs | 154 +++++++++++++++++++++ scripts/client-workbench-test-env.test.mjs | 77 +++++++++++ 3 files changed, 233 insertions(+) create mode 100644 scripts/client-workbench-test-env.mjs create mode 100644 scripts/client-workbench-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 2ec1b1597..47f0477bd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`client-workbench-test-env.mjs` 是客户订单工作台只读 TEST 分页验收入口,默认 `--plan` 零网络。Firstleg/Order 新工作台接口部署后,以显式 `node --env-file=<用户指定目录的test-acceptance.env> scripts/client-workbench-test-env.mjs --execute --environment=TEST --run-id=<新批次>` 执行;独立客户登录及退出是唯一 POST,不创建订单、不写 SQL、不调用会补建预入库记录的旧草稿列表。要求稳定样本超过 200 条,遍历完整工作台及独立统一订单集合,核对完整正式单集合、费用/草稿待办、报关队列分页数量、21页切片、渠道筛选和伪造客户参数。报关业务资格、第二客户负向权限、页面及写后刷新仍需另验。报告独占 `reports/client-workbench-/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。 + `fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports//handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。 `fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 diff --git a/scripts/client-workbench-test-env.mjs b/scripts/client-workbench-test-env.mjs new file mode 100644 index 000000000..ea9b845a8 --- /dev/null +++ b/scripts/client-workbench-test-env.mjs @@ -0,0 +1,154 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const endpoint = '/merchant/v1/orders/workbench' +class VerificationError extends Error {} +function requireFact(value, message) { if (!value) throw new VerificationError(message) } +const ids = rows => rows.map(row => String(row.orderId)) +const equalIds = (a, b) => JSON.stringify(ids(a).sort()) === JSON.stringify(ids(b).sort()) +const digest = rows => createHash('sha256').update(JSON.stringify(ids(rows).sort())).digest('hex') + +export function executionOptions(args, execArgs, env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false } + requireFact(args.length === 3 && args[0] === '--execute' && args[1] === '--environment=TEST' + && /^--run-id=[A-Za-z0-9_-]{1,60}$/.test(args[2]), 'Explicit TEST execution and a new run-id are required') + const files = execArgs.filter(arg => arg.startsWith('--env-file=')) + requireFact(files.length === 1 && execArgs.length === 1 && resolve(files[0].slice(11)).replaceAll('\\', '/') + .endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Use the explicitly authorized test-acceptance.env') + requireFact(String(env.CHONGCHONG_ENV).toUpperCase() === 'TEST', 'TEST profile required') + for (const key of ['CHONGCHONG_MERCHANT_API_BASE_URL', 'CHONGCHONG_MERCHANT_USERNAME', 'CHONGCHONG_MERCHANT_PASSWORD']) + requireFact(Boolean(env[key]), `Missing ${key}`) + const base = new URL(env.CHONGCHONG_MERCHANT_API_BASE_URL) + requireFact(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password + && base.pathname.replace(/\/$/, '') === '/api', 'Expected the configured TEST API base') + return { execute: true, runId: args[2].slice(9), base: base.href.replace(/\/$/, '') } +} + +export function validatePage(page, number, size) { + const total = Number(page?.total), pages = Number(page?.pages) + requireFact(Number.isSafeInteger(total) && total >= 0 && Number(page.page) === number + && Number(page.size) === size && pages === Math.ceil(total / size), 'Invalid page metadata') + requireFact(Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, total - (number - 1) * size)), 'Truncated page') + requireFact(page.records.every(row => row.orderId != null) && new Set(ids(page.records)).size === page.records.length, 'Missing or duplicate order identity') + return page +} + +/** GET-only verifier. Caller owns authentication and durable, sanitized request evidence. */ +export async function verifyWorkbench(read, customerId) { + const checks = [] + const check = (name, ok) => { requireFact(ok, name); checks.push({ name, passed: true }) } + async function all(path, workbench = false, params = {}) { + let rows = [], expected = null, first + for (let number = 1; ; number++) { + const query = new URLSearchParams({ ...params, page: String(number), size: '200' }) + const response = await read(`${path}?${query}`) + const page = validatePage(workbench ? response.page : response, number, 200) + if (number === 1) { expected = Number(page.total); first = response } + requireFact(Number(page.total) === expected, 'Dataset changed while paging; rerun in a stable window') + requireFact(page.records.every(row => String(row.tenantId) === String(customerId)), 'Customer scope mismatch') + rows.push(...page.records) + if (number >= Number(page.pages)) break + requireFact(number < 1000, 'Pagination safety bound exceeded') + } + requireFact(rows.length === expected && new Set(ids(rows)).size === expected, 'Duplicate or missing orders across pages') + return { rows, first } + } + const workbench = await all(endpoint, true) + check('sample actually exceeds the former 200-order limit', workbench.rows.length > 200) + const formal = await all('/merchant/v1/orders') + check('all formal orders match the independent unified query', equalIds(workbench.rows.filter(r => r.mainStatus !== 'DRAFT'), formal.rows)) + const queues = workbench.first.queues + for (const name of ['all', 'fee', 'customs', 'draft']) { + check(`queue ${name} has a valid count`, Number.isSafeInteger(Number(queues?.[name])) && Number(queues[name]) >= 0) + } + check('all queue count matches complete rows', Number(queues.all) === workbench.rows.length) + check('fee count matches authoritative confirmation flags', Number(queues.fee) === workbench.rows.filter(r => r.customerFeeConfirmationRequired === true).length) + check('draft count matches draft rows', Number(queues.draft) === workbench.rows.filter(r => r.mainStatus === 'DRAFT').length) + for (const queue of ['fee', 'customs', 'draft']) { + const selected = await all(endpoint, true, { queue }) + check(`${queue} complete page count`, selected.rows.length === Number(queues[queue])) + const own = new Set(ids(workbench.rows)) + check(`${queue} contains only listed customer orders`, ids(selected.rows).every(id => own.has(id))) + if (queue === 'fee') check('fee queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.customerFeeConfirmationRequired === true))) + if (queue === 'draft') check('draft queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.mainStatus === 'DRAFT'))) + } + for (const [page, size] of [[21, 10], [2, 50]]) { + const result = await read(`${endpoint}?page=${page}&size=${size}`) + validatePage(result.page, page, size) + check(`page ${page} size ${size} preserves exact ordered slice`, JSON.stringify(ids(result.page.records)) + === JSON.stringify(ids(workbench.rows.slice((page - 1) * size, page * size)))) + } + const channels = workbench.first.channelNames + check('channel options include every listed channel', Array.isArray(channels) + && workbench.rows.every(r => !r.channelName || channels.includes(r.channelName))) + const channel = workbench.rows.find(r => r.channelName)?.channelName + requireFact(channel, 'A channel-filter sample is required') + const filtered = await all(endpoint, true, { channelName: channel }) + check('channel filter applies before counting and paging', equalIds(filtered.rows, workbench.rows.filter(r => r.channelName === channel))) + const spoofed = await read(`${endpoint}?page=1&size=200&tenantId=0&customerId=0`) + check('spoofed customer parameters cannot broaden scope', equalIds(spoofed.page.records, workbench.first.page.records) + && Number(spoofed.page.total) === workbench.rows.length) + const refreshed = await read(`${endpoint}?page=1&size=200`) + check('dataset stayed stable through verification', Number(refreshed.page.total) === workbench.rows.length + && JSON.stringify(ids(refreshed.page.records)) === JSON.stringify(ids(workbench.first.page.records))) + return { checks, total: workbench.rows.length, formalTotal: formal.rows.length, + membershipSha256: digest(workbench.rows), queues, + limitations: ['customs queue business membership requires separate customs evidence', 'GET evidence does not replace browser or second-customer authorization tests'] } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + const options = executionOptions(args, execArgs, env) + if (!options.execute) return { plan: true, businessWrites: false, checks: ['complete pagination', 'independent formal-order set', 'queue counts', 'page 21', 'channel filtering', 'customer parameter isolation'], required: 'Deploy Firstleg and Order workbench APIs first; use a stable customer dataset with more than 200 rows' } + const directory = resolve(root, 'reports', `client-workbench-${options.runId}`) + await mkdir(directory) // Exclusive run: existing evidence is never replaced by a rerun. + const report = { startedAt: new Date().toISOString(), readOnlyBusiness: true, success: false, requests: [], sessionClosed: false } + const save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(report, null, 2)) + let token = '', refresh = '' + async function request(method, path, body) { + requireFact(method === 'GET' || method === 'POST' && ['/auth/login', '/auth/logout'].includes(path), 'Business writes prohibited') + const query = new URLSearchParams(path.split('?')[1] || '') + const entry = { method, path: path.split('?')[0], outcome: 'PENDING', + page: query.get('page') || undefined, size: query.get('size') || undefined, queue: query.get('queue') || undefined, + queryKeys: [...query.keys()], querySha256: createHash('sha256').update(query.toString()).digest('hex') } + report.requests.push(entry); await save() + try { + const response = await fetch(options.base + path, { method, redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined }) + const payload = await response.json() + Object.assign(entry, { outcome: 'RECEIVED', http: response.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId }) + await save() + const safeCode = /^[A-Z0-9]{1,16}$/.test(String(payload.code)) ? String(payload.code) : 'INVALID_CODE' + requireFact(response.ok && String(payload.code) === '200', `API rejected ${entry.path}: HTTP ${response.status}, code ${safeCode}`) + return payload.data + } catch (error) { if (entry.outcome === 'PENDING') entry.outcome = 'UNKNOWN'; await save(); throw error } + } + try { + const login = await request('POST', '/auth/login', { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }) + token = login?.accessToken || ''; refresh = login?.refreshToken || '' + requireFact(token && refresh, 'Incomplete authentication response') + const profile = await request('GET', '/merchant/v1/iam/tenant/profile') + requireFact(profile?.id != null, 'Missing authenticated customer profile') + Object.assign(report, await verifyWorkbench(path => request('GET', path), profile.id)) + report.success = true + } catch (error) { + report.success = false + // Fixed verifier/HTTP classifications only; never persist an arbitrary response body or credential. + report.failure = error instanceof VerificationError ? error.message : 'Transport or response format failure' + } finally { + if (token) { + try { await request('POST', '/auth/logout', { refreshToken: refresh }); report.sessionClosed = true } + catch { report.success = false; report.logoutFailure = true } + } + report.success = report.success && report.sessionClosed + report.completedAt = new Date().toISOString(); await save() + } + return { success: report.success, sessionClosed: report.sessionClosed, report: resolve(directory, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Workbench verification did not start; check explicit TEST profile and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-workbench-test-env.test.mjs b/scripts/client-workbench-test-env.test.mjs new file mode 100644 index 000000000..6e488a1f1 --- /dev/null +++ b/scripts/client-workbench-test-env.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { executionOptions, validatePage, verifyWorkbench } from './client-workbench-test-env.mjs' + +const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/api', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' } +const args = ['--execute', '--environment=TEST', '--run-id=fixture'] +const execArgs = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] +test('plan has no credential or environment dependency', () => { + assert.deepEqual(executionOptions([], [], {}), { execute: false }) + assert.deepEqual(executionOptions(['--plan'], [], {}), { execute: false }) +}) +test('execution requires explicit TEST profile, safe run id and API prefix', () => { + assert.equal(executionOptions(args, execArgs, env).runId, 'fixture') + for (const [a, x, e] of [ + [args, [], env], [args, execArgs, { ...env, CHONGCHONG_ENV: 'PROD' }], + [args, [execArgs[0].replace('test-acceptance', 'prod-acceptance')], env], + [['--execute', '--environment=TEST', '--run-id=../escape'], execArgs, env], + [[...args, '--resume'], execArgs, env], + [args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/' }], + ]) assert.throws(() => executionOptions(a, x, e)) +}) +function fixture() { + const rows = Array.from({ length: 215 }, (_, i) => ({ orderId: String(1000 + i), tenantId: '11', + mainStatus: i < 3 ? 'DRAFT' : 'LOADED', customerFeeConfirmationRequired: i >= 10 && i < 12, + channelName: i % 2 ? 'SEA' : 'AIR' })) + const calls = [] + const queues = list => ({ all: list.length, fee: list.filter(r => r.customerFeeConfirmationRequired).length, + customs: list.filter(r => r.orderId === '1012').length, draft: list.filter(r => r.mainStatus === 'DRAFT').length }) + function read(path) { + calls.push(path) + const url = new URL(path, 'https://test.invalid'), q = url.searchParams + let selected = rows.filter(r => !q.get('channelName') || r.channelName === q.get('channelName')) + const counts = queues(selected) + if (url.pathname === '/merchant/v1/orders') selected = selected.filter(r => r.mainStatus !== 'DRAFT') + if (q.get('queue') === 'fee') selected = selected.filter(r => r.customerFeeConfirmationRequired) + if (q.get('queue') === 'customs') selected = selected.filter(r => r.orderId === '1012') + if (q.get('queue') === 'draft') selected = selected.filter(r => r.mainStatus === 'DRAFT') + const number = Number(q.get('page')), size = Number(q.get('size')) + const page = { page: number, size, total: selected.length, pages: Math.ceil(selected.length / size), records: selected.slice((number - 1) * size, number * size).map(r => ({ ...r })) } + return url.pathname.endsWith('/workbench') ? { page, queues: counts, channelNames: ['AIR', 'SEA'] } : page + } + return { read, calls, rows } +} +test('complete traversal proves beyond-200 data, independent formal membership, queues and filters', async () => { + const h = fixture(), proof = await verifyWorkbench(h.read, '11') + assert.equal(proof.total, 215); assert.equal(proof.formalTotal, 212) + assert.ok(proof.checks.length >= 19); assert.equal(proof.membershipSha256.length, 64) + assert.ok(h.calls.includes('/merchant/v1/orders/workbench?page=21&size=10')) + assert.equal(proof.limitations.length, 2) +}) +for (const [name, mutate, expected] of [ + ['first-200 truncation', (r, path) => { if (path.includes('workbench?') && path.includes('page=1&size=200')) { r.page.total = 200; r.page.pages = 1 } }, /former 200/], + ['wrong page metadata', r => { (r.page ?? r).size = 10 }, /metadata/], + ['customer leakage', r => { (r.page ?? r).records[0].tenantId = '22' }, /Customer scope/], + ['duplicate identity', r => { const p = r.page ?? r; if (p.records.length > 1) p.records[1] = p.records[0] }, /duplicate/], + ['partial response', r => { (r.page ?? r).records.pop() }, /Truncated/], + ['wrong fee count', r => { if (r.queues) r.queues.fee = 99 }, /fee count/], + ['wrong draft count', r => { if (r.queues) r.queues.draft = 0 }, /draft count/], + ['missing channel option', r => { if (r.queues) r.channelNames = ['SEA'] }, /channel options/], + ['wrong page 21 slice', (r, path) => { if (path.includes('page=21&size=10')) r.page.records.reverse() }, /ordered slice/], +]) test(`refuses ${name}`, async () => { + const h = fixture() + await assert.rejects(verifyWorkbench(path => { const response = h.read(path); mutate(response, path); return response }, '11'), expected) +}) +test('changed independent formal set is a failure, not partial success', async () => { + const h = fixture() + await assert.rejects(verifyWorkbench(path => { + const result = h.read(path) + if (!path.includes('/workbench') && path.includes('page=1&') && result.records.length) result.records[0].orderId = 'foreign-formal' + return result + }, '11'), /independent unified/) +}) +test('empty out-of-range pages validate while impossible totals and missing ids fail', () => { + assert.equal(validatePage({ page: 3, size: 10, total: 20, pages: 2, records: [] }, 3, 10).total, 20) + assert.throws(() => validatePage({ page: 1, size: 10, total: -1, pages: 0, records: [] }, 1, 10)) + assert.throws(() => validatePage({ page: 1, size: 10, total: 1, pages: 1, records: [{}] }, 1, 10)) +}) -- 2.54.0 From 18935f1379d44225217183a536517a70b7d6f95d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 15:53:55 +0800 Subject: [PATCH 101/176] test(order): verify live import template directories without business writes --- scripts/README.md | 2 + scripts/client-import-template-inspect.py | 193 ++++++++++++++++++ .../client-import-template-inspect.test.py | 53 +++++ scripts/client-import-template-test-env.mjs | 25 +++ scripts/client-workbench-test-env.mjs | 4 +- scripts/client-workbench-test-env.test.mjs | 7 +- 6 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 scripts/client-import-template-inspect.py create mode 100644 scripts/client-import-template-inspect.test.py create mode 100644 scripts/client-import-template-test-env.mjs diff --git a/scripts/README.md b/scripts/README.md index 47f0477bd..42898ab08 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。 + `client-workbench-test-env.mjs` 是客户订单工作台只读 TEST 分页验收入口,默认 `--plan` 零网络。Firstleg/Order 新工作台接口部署后,以显式 `node --env-file=<用户指定目录的test-acceptance.env> scripts/client-workbench-test-env.mjs --execute --environment=TEST --run-id=<新批次>` 执行;独立客户登录及退出是唯一 POST,不创建订单、不写 SQL、不调用会补建预入库记录的旧草稿列表。要求稳定样本超过 200 条,遍历完整工作台及独立统一订单集合,核对完整正式单集合、费用/草稿待办、报关队列分页数量、21页切片、渠道筛选和伪造客户参数。报关业务资格、第二客户负向权限、页面及写后刷新仍需另验。报告独占 `reports/client-workbench-/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。 `fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports//handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。 diff --git a/scripts/client-import-template-inspect.py b/scripts/client-import-template-inspect.py new file mode 100644 index 000000000..bc86a6079 --- /dev/null +++ b/scripts/client-import-template-inspect.py @@ -0,0 +1,193 @@ +"""Read-only TEST template audit. Credentials and workbook bytes stay in memory.""" +import hashlib +import io +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from openpyxl import load_workbook + + +def require(value, message): + if not value: + raise AssertionError(message) + + +def labels(values, labeler): + result = [] + for item in values: + base = labeler(item) + label, n = base, 2 + while label in result: + label = f"{base}(同名选项 {n})" + n += 1 + result.append(label) + return result + + +def label(name, code): + return code if not name or name.lower() == code.lower() else f"{name}|{code}" + + +def compare_options(actual, expected): + return {"expectedCount": len(expected), "actualCount": len(actual), + "missingCount": len(set(expected) - set(actual)), + "extraCount": len(set(actual) - set(expected)), + "duplicateCount": len(actual) - len(set(actual)), + "passed": sorted(actual) == sorted(expected), + "actualSha256": hashlib.sha256(json.dumps(sorted(actual), ensure_ascii=False).encode()).hexdigest()} + + +def inspect_template(content, expected): + book = load_workbook(io.BytesIO(content), data_only=False) + try: + require(set(book.sheetnames) == {"订单导入", "选项数据", "填写说明"}, "Unexpected workbook sheets") + require(book["选项数据"].sheet_state == "hidden", "Options sheet must be hidden") + sheet = book["订单导入"] + headers = {str(cell.value).removesuffix(" *"): cell.column for cell in sheet[2] if cell.value} + checks = {} + for title, values in expected.items(): + require(title in headers, "Missing expected header") + col = headers[title] + validations = [v for v in sheet.data_validations.dataValidation + if any(r.min_col <= col <= r.max_col and r.min_row <= 3 <= r.max_row for r in v.sqref.ranges)] + if not values: + require(not validations, "Empty customer directory unexpectedly has choices") + checks[title] = compare_options([], []) + continue + require(len(validations) == 1, "Missing or overlapping dropdown validation") + validation = validations[0] + require(validation.type == "list" and validation.showErrorMessage, "Dropdown must reject invalid choices") + require(any(r.min_row == 3 and r.max_row == 52 and r.min_col == col and r.max_col == col + for r in validation.sqref.ranges), "Dropdown must cover all 50 input rows") + name = book.defined_names.get(validation.formula1) + require(name is not None, "Dropdown name does not resolve") + destinations = list(name.destinations) + require(len(destinations) == 1 and destinations[0][0] == "选项数据", "Invalid dropdown destination") + actual = [cell.value for row in book[destinations[0][0]][destinations[0][1]] for cell in row] + require(all(isinstance(v, str) and v for v in actual), "Dropdown contains blank or invalid entries") + checks[title] = compare_options(actual, values) + return {"checks": checks, "passed": all(v["passed"] for v in checks.values()), + "workbookSha256": hashlib.sha256(content).hexdigest(), "headerCount": len(headers), + "dropdowns": len(sheet.data_validations.dataValidation)} + finally: + book.close() + + +def main(config): + require(config.get("environment") == "TEST" and re.fullmatch(r"[A-Za-z0-9_-]{1,60}", config.get("runId", "")), "Explicit TEST run required") + directory = Path(__file__).resolve().parent.parent / "reports" / ("client-template-" + config["runId"]) + directory.mkdir() # Never overwrite an earlier run. + report_path = directory / "report.json" + report = {"startedAt": datetime.now(timezone.utc).isoformat(), "readOnlyBusiness": True, + "success": False, "sessionClosed": False, "requests": []} + token, refresh = "", "" + + def save(): + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + def request(method, path, body=None, binary=False): + require(method == "GET" or method == "POST" and path in ["/auth/login", "/auth/logout"], "Business writes forbidden") + parsed = urllib.parse.urlsplit(path) + query = urllib.parse.parse_qs(parsed.query) + record = {"method": method, "path": parsed.path, "outcome": "PENDING", + "page": query.get("page"), "size": query.get("size")} + report["requests"].append(record) + save() + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + if body is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(config["base"] + path, method=method, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + try: + with urllib.request.urlopen(req, timeout=30) as response: + data = response.read() + record.update(outcome="RECEIVED", http=response.status, + requestId=response.headers.get("X-Request-ID"), traceId=response.headers.get("X-Trace-ID")) + if binary: + require(data[:2] == b"PK", "Template response is not XLSX") + record["sha256"] = hashlib.sha256(data).hexdigest() + save() + return data + payload = json.loads(data) + record.update(code=payload.get("code"), requestId=payload.get("requestId") or record["requestId"], + traceId=payload.get("traceId") or record["traceId"]) + save() + require(str(payload.get("code")) == "200", "API business response rejected") + return payload["data"] + except urllib.error.HTTPError as error: + record.update(outcome="RECEIVED", http=error.code) + save() + raise AssertionError("HTTP response rejected") from None + except Exception: + if record["outcome"] == "PENDING": + record["outcome"] = "UNKNOWN" + save() + raise + + try: + auth = request("POST", "/auth/login", {"loginId": config["username"], "password": config["password"]}) + token, refresh = auth.get("accessToken", ""), auth.get("refreshToken", "") + require(token and refresh, "Incomplete login") + warehouses, total, page_no = [], None, 1 + while True: + page = request("GET", f"/merchant/v1/warehouses?status=ACTIVE&page={page_no}&size=200") + if total is None: + total = int(page["total"]) + require(int(page["page"]) == page_no and int(page["size"]) == 200 and int(page["total"]) == total + and int(page["pages"]) == (total + 199) // 200, "Warehouse pagination changed") + require(len(page["records"]) == min(200, max(0, total - (page_no - 1) * 200)), "Warehouse page incomplete") + warehouses.extend(page["records"]) + if page_no >= int(page["pages"]): + break + require(page_no < 1000, "Warehouse page limit exceeded") + page_no += 1 + require(len({str(w["id"]) for w in warehouses}) == total and all(w["status"] == "ACTIVE" for w in warehouses), "Invalid warehouse set") + expected = {} + for kind, title in [("DOMESTIC", "国内收货仓"), ("FBA", "FBA目的仓"), ("OVERSEAS", "海外仓目的仓")]: + expected[title] = labels([w for w in warehouses if w["warehouseType"] == kind], lambda w: label(w["warehouseName"], w["warehouseCode"])) + channels = [] + for warehouse in warehouses: + if warehouse["warehouseType"] != "DOMESTIC": + continue + values = request("GET", "/merchant/v1/first-leg/pricing/channels?originWarehouseId=" + str(warehouse["id"])) + channels.extend(label(c["channelName"], c["channelCode"]) for c in values) + expected["运输渠道"] = list(dict.fromkeys(channels)) + for kind, title in [("PICKUP", "取货地址"), ("PRIVATE_DESTINATION", "私人目的仓地址")]: + addresses = request("GET", "/merchant/v1/orders/first-leg/addresses?addressType=" + kind) + expected[title] = labels(addresses, lambda a: "|".join(a[key] for key in ["addressName", "contactName", "city", "addressLine"])) + content = request("GET", "/merchant/v1/orders/first-leg/batch-imports/template", binary=True) + report["template"] = inspect_template(content, expected) + report["warehousePages"] = page_no + report["warehouseTotal"] = total + report["success"] = report["template"]["passed"] + report["limitations"] = ["No import submission or Excel desktop interaction performed", + "Second-customer address rejection requires separate evidence"] + except Exception as error: + report["failure"] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + finally: + if token: + try: + request("POST", "/auth/logout", {"refreshToken": refresh}) + report["sessionClosed"] = True + except Exception: + report["logoutFailure"] = True + report["success"] = report["success"] and report["sessionClosed"] + report["completedAt"] = datetime.now(timezone.utc).isoformat() + save() + print(json.dumps({"success": report["success"], "sessionClosed": report["sessionClosed"], "report": str(report_path)})) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + try: + sys.exit(main(json.load(sys.stdin))) + except Exception: + print("Template audit did not start; verify the new report directory and runtime.", file=sys.stderr) + sys.exit(1) diff --git a/scripts/client-import-template-inspect.test.py b/scripts/client-import-template-inspect.test.py new file mode 100644 index 000000000..2dda6fbed --- /dev/null +++ b/scripts/client-import-template-inspect.test.py @@ -0,0 +1,53 @@ +import importlib.util +import io +import unittest +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +spec = importlib.util.spec_from_file_location("template_audit", Path(__file__).with_name("client-import-template-inspect.py")) +audit = importlib.util.module_from_spec(spec) +spec.loader.exec_module(audit) + + +def fixture(options=("测试仓|WH-A", "测试仓|WH-B"), rows="A3:A52", target="option_0", hidden=True): + ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + body = io.BytesIO() + with zipfile.ZipFile(body, "w") as z: + z.writestr("[Content_Types].xml", '') + z.writestr("_rels/.rels", f'') + z.writestr("xl/workbook.xml", f'选项数据!$A$1:$A${len(options)}') + z.writestr("xl/_rels/workbook.xml.rels", '' + ''.join(f'' for i in (1, 2, 3)) + '') + z.writestr("xl/worksheets/sheet1.xml", f'国内收货仓 *{target}') + data = ''.join(f'{escape(value)}' for i, value in enumerate(options, 1)) + z.writestr("xl/worksheets/sheet2.xml", f'{data}') + z.writestr("xl/worksheets/sheet3.xml", f'') + return body.getvalue() + + +class TemplateAuditTest(unittest.TestCase): + def test_reads_actual_xlsx_names_validation_and_all_options(self): + result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]}) + self.assertTrue(result["passed"]) + self.assertEqual(result["checks"]["国内收货仓"]["actualCount"], 2) + + def test_missing_extra_and_duplicate_options_are_reported_without_values(self): + result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "其他仓|WH-C"]}) + self.assertFalse(result["passed"]) + self.assertEqual(result["checks"]["国内收货仓"]["missingCount"], 1) + self.assertEqual(result["checks"]["国内收货仓"]["extraCount"], 1) + self.assertNotIn("WH-A", str(result)) + self.assertFalse(audit.compare_options(["A", "A"], ["A"])["passed"]) + + def test_refuses_short_ranges_unresolved_names_and_exposed_options(self): + for options in ({"rows": "A3:A51"}, {"target": "missing"}, {"hidden": False}): + with self.assertRaises(AssertionError): + audit.inspect_template(fixture(**options), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]}) + + def test_duplicate_business_labels_remain_distinct_choices(self): + self.assertEqual(audit.labels(["A", "A", "A"], lambda x: x), ["A", "A(同名选项 2)", "A(同名选项 3)"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/client-import-template-test-env.mjs b/scripts/client-import-template-test-env.mjs new file mode 100644 index 000000000..250747351 --- /dev/null +++ b/scripts/client-import-template-test-env.mjs @@ -0,0 +1,25 @@ +import { spawn } from 'node:child_process' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { executionOptions } from './client-workbench-test-env.mjs' + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, businessWrites: false, + checks: ['full warehouse catalog', 'channel union', 'own customer addresses', 'named dropdown ranges', '50 input rows'], + required: 'Explicit TEST profile, new run-id, and --python=' } + if (args.length !== 4 || !args[3].startsWith('--python=')) throw new Error('Explicit bundled Python required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const python = resolve(args[3].slice(9)) + if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw new Error('Use bundled workspace Python') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const config = { environment: 'TEST', base: options.base, username: env.CHONGCHONG_MERCHANT_USERNAME, + password: env.CHONGCHONG_MERCHANT_PASSWORD, runId: options.runId } + const child = spawn(python, [resolve(root, 'scripts/client-import-template-inspect.py')], { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) + child.stdin.end(JSON.stringify(config)) + const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) + return { success: code === 0, report: resolve(root, 'reports', `client-template-${options.runId}`, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Template audit did not start. Check explicit TEST profile, runtime and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-workbench-test-env.mjs b/scripts/client-workbench-test-env.mjs index ea9b845a8..349452c5a 100644 --- a/scripts/client-workbench-test-env.mjs +++ b/scripts/client-workbench-test-env.mjs @@ -23,8 +23,8 @@ export function executionOptions(args, execArgs, env) { requireFact(Boolean(env[key]), `Missing ${key}`) const base = new URL(env.CHONGCHONG_MERCHANT_API_BASE_URL) requireFact(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password - && base.pathname.replace(/\/$/, '') === '/api', 'Expected the configured TEST API base') - return { execute: true, runId: args[2].slice(9), base: base.href.replace(/\/$/, '') } + && base.pathname === '/' && !base.search && !base.hash, 'Expected the configured TEST Gateway origin') + return { execute: true, runId: args[2].slice(9), base: base.origin + '/api' } } export function validatePage(page, number, size) { diff --git a/scripts/client-workbench-test-env.test.mjs b/scripts/client-workbench-test-env.test.mjs index 6e488a1f1..4240a7bdd 100644 --- a/scripts/client-workbench-test-env.test.mjs +++ b/scripts/client-workbench-test-env.test.mjs @@ -2,21 +2,22 @@ import test from 'node:test' import assert from 'node:assert/strict' import { executionOptions, validatePage, verifyWorkbench } from './client-workbench-test-env.mjs' -const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/api', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' } +const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' } const args = ['--execute', '--environment=TEST', '--run-id=fixture'] const execArgs = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] test('plan has no credential or environment dependency', () => { assert.deepEqual(executionOptions([], [], {}), { execute: false }) assert.deepEqual(executionOptions(['--plan'], [], {}), { execute: false }) }) -test('execution requires explicit TEST profile, safe run id and API prefix', () => { +test('execution requires explicit TEST profile, safe run id and Gateway origin', () => { assert.equal(executionOptions(args, execArgs, env).runId, 'fixture') + assert.equal(executionOptions(args, execArgs, env).base, 'https://test.invalid/api') for (const [a, x, e] of [ [args, [], env], [args, execArgs, { ...env, CHONGCHONG_ENV: 'PROD' }], [args, [execArgs[0].replace('test-acceptance', 'prod-acceptance')], env], [['--execute', '--environment=TEST', '--run-id=../escape'], execArgs, env], [[...args, '--resume'], execArgs, env], - [args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/' }], + [args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/other' }], ]) assert.throws(() => executionOptions(a, x, e)) }) function fixture() { -- 2.54.0 From 8f356b1337dbc24f29ce6f792c42fd978c5d4640 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 15:57:36 +0800 Subject: [PATCH 102/176] test(order): compare import templates across independent test customers --- scripts/README.md | 2 ++ scripts/client-import-template-inspect.py | 3 ++ scripts/client-import-template-test-env.mjs | 33 ++++++++++++++++++- .../client-import-template-test-env.test.mjs | 28 ++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 scripts/client-import-template-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 42898ab08..243ab654d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 + `client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。 `client-workbench-test-env.mjs` 是客户订单工作台只读 TEST 分页验收入口,默认 `--plan` 零网络。Firstleg/Order 新工作台接口部署后,以显式 `node --env-file=<用户指定目录的test-acceptance.env> scripts/client-workbench-test-env.mjs --execute --environment=TEST --run-id=<新批次>` 执行;独立客户登录及退出是唯一 POST,不创建订单、不写 SQL、不调用会补建预入库记录的旧草稿列表。要求稳定样本超过 200 条,遍历完整工作台及独立统一订单集合,核对完整正式单集合、费用/草稿待办、报关队列分页数量、21页切片、渠道筛选和伪造客户参数。报关业务资格、第二客户负向权限、页面及写后刷新仍需另验。报告独占 `reports/client-workbench-/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。 diff --git a/scripts/client-import-template-inspect.py b/scripts/client-import-template-inspect.py index bc86a6079..0280bb4ed 100644 --- a/scripts/client-import-template-inspect.py +++ b/scripts/client-import-template-inspect.py @@ -135,6 +135,9 @@ def main(config): auth = request("POST", "/auth/login", {"loginId": config["username"], "password": config["password"]}) token, refresh = auth.get("accessToken", ""), auth.get("refreshToken", "") require(token and refresh, "Incomplete login") + profile = request("GET", "/merchant/v1/iam/tenant/profile") + require(profile.get("id") is not None, "Missing authenticated customer identity") + report["customerIdentitySha256"] = hashlib.sha256(str(profile["id"]).encode()).hexdigest() warehouses, total, page_no = [], None, 1 while True: page = request("GET", f"/merchant/v1/warehouses?status=ACTIVE&page={page_no}&size=200") diff --git a/scripts/client-import-template-test-env.mjs b/scripts/client-import-template-test-env.mjs index 250747351..ab2ef9620 100644 --- a/scripts/client-import-template-test-env.mjs +++ b/scripts/client-import-template-test-env.mjs @@ -1,4 +1,6 @@ import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { parseEnv } from 'node:util' import { resolve, dirname } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { executionOptions } from './client-workbench-test-env.mjs' @@ -7,18 +9,47 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, businessWrites: false, checks: ['full warehouse catalog', 'channel union', 'own customer addresses', 'named dropdown ranges', '50 input rows'], required: 'Explicit TEST profile, new run-id, and --python=' } - if (args.length !== 4 || !args[3].startsWith('--python=')) throw new Error('Explicit bundled Python required') + if (![4, 5].includes(args.length) || !args[3].startsWith('--python=')) throw new Error('Explicit bundled Python required') const options = executionOptions(args.slice(0, 3), execArgs, env) const python = resolve(args[3].slice(9)) if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw new Error('Use bundled workspace Python') const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const config = { environment: 'TEST', base: options.base, username: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD, runId: options.runId } + if (args[4]) { + const profile = customerProfilePath(args[4], execArgs[0].slice(11)) + const credentials = parseEnv(await readFile(profile, 'utf8')) + if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw new Error('Missing TEST customer credentials') + config.username = credentials.MERCHANT_TEST_USERNAME + config.password = credentials.MERCHANT_TEST_PASSWORD + } const child = spawn(python, [resolve(root, 'scripts/client-import-template-inspect.py')], { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) child.stdin.end(JSON.stringify(config)) const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) return { success: code === 0, report: resolve(root, 'reports', `client-template-${options.runId}`, 'report.json') } } +export function customerProfilePath(argument, environmentFile) { + if (!argument.startsWith('--customer-profile=')) throw new Error('Unknown argument') + const profile = resolve(argument.slice(19)) + if (dirname(profile) !== dirname(resolve(environmentFile)) || !/^test-ui-[A-Za-z0-9_-]+\.env$/.test(profile.split(/[\\/]/).at(-1))) + throw new Error('Customer profile must be an explicit TEST UI file beside the authorized TEST configuration') + return profile +} +export function compareCustomerTemplates(primary, control) { + const checks = { + bothRunsPassed: primary.success === true && control.success === true, + bothSessionsClosed: primary.sessionClosed === true && control.sessionClosed === true, + differentCustomers: typeof primary.customerIdentitySha256 === 'string' && typeof control.customerIdentitySha256 === 'string' + && primary.customerIdentitySha256 !== control.customerIdentitySha256, + primaryHasPrivateAddress: primary.template?.checks?.['私人目的仓地址']?.actualCount > 0, + controlHasNoPrivateAddress: control.template?.checks?.['私人目的仓地址']?.actualCount === 0, + privateOptionsMatchOwnDirectory: [primary, control].every(r => r.template?.checks?.['私人目的仓地址']?.passed === true), + pickupOptionsMatchOwnDirectory: [primary, control].every(r => r.template?.checks?.['取货地址']?.passed === true), + differentPickupAddressSamples: primary.template?.checks?.['取货地址']?.actualSha256 !== control.template?.checks?.['取货地址']?.actualSha256, + } + return { success: Object.values(checks).every(Boolean), checks, + limitation: 'Read-time customer isolation only; importing another customer address remains a separate negative test' } +} if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) .catch(() => { console.error('Template audit did not start. Check explicit TEST profile, runtime and new run-id.'); process.exitCode = 1 }) diff --git a/scripts/client-import-template-test-env.test.mjs b/scripts/client-import-template-test-env.test.mjs new file mode 100644 index 000000000..0d5de2e5d --- /dev/null +++ b/scripts/client-import-template-test-env.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { customerProfilePath, compareCustomerTemplates, execute } from './client-import-template-test-env.mjs' + +const directory = 'C:/Users/BY-OY/.codex/env/chongchong-global' +test('template plan performs no login and requires no runtime', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) +}) +test('cross-customer read evidence rejects same identity, leaked options and incomplete cleanup', () => { + const report = (id, privateCount) => ({ success: true, sessionClosed: true, customerIdentitySha256: id, + template: { checks: { '私人目的仓地址': { actualCount: privateCount, passed: true }, '取货地址': { actualSha256: id, passed: true } } } }) + const primary = report('customer-A', 1), control = report('customer-B', 0) + assert.equal(compareCustomerTemplates(primary, control).success, true) + assert.equal(compareCustomerTemplates(primary, primary).success, false) + assert.equal(compareCustomerTemplates(primary, report('customer-B', 1)).success, false) + assert.equal(compareCustomerTemplates(primary, { ...control, sessionClosed: false }).success, false) + const failed = report('customer-B', 0); failed.template.checks['私人目的仓地址'].passed = false + assert.equal(compareCustomerTemplates(primary, failed).success, false) +}) +test('explicit existing TEST UI customer profile cannot select production or escape the authorized directory', () => { + const argument = `--customer-profile=${directory}/test-ui-fixture.env` + assert.equal(customerProfilePath(argument, directory + '/test-acceptance.env'), resolve(directory, 'test-ui-fixture.env')) + for (const value of ['prod-acceptance.env', '../test-ui-fixture.env', 'test-database.env', 'test-ui-fixture.env/another.env']) { + assert.throws(() => customerProfilePath(`--customer-profile=${directory}/${value}`, directory + '/test-acceptance.env')) + } + assert.throws(() => customerProfilePath('--unknown=file', directory + '/test-acceptance.env')) +}) -- 2.54.0 From e740047e03aae4edb9922f0377ff8a39e163322d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 16:03:34 +0800 Subject: [PATCH 103/176] test(order): prove foreign customer address rejection through real API probes --- scripts/README.md | 2 + scripts/client-address-isolation-test-env.mjs | 134 ++++++++++++++++++ ...client-address-isolation-test-env.test.mjs | 27 ++++ 3 files changed, 163 insertions(+) create mode 100644 scripts/client-address-isolation-test-env.mjs create mode 100644 scripts/client-address-isolation-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 243ab654d..8588d99d4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 + 模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 `client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。 diff --git a/scripts/client-address-isolation-test-env.mjs b/scripts/client-address-isolation-test-env.mjs new file mode 100644 index 000000000..5df8763e9 --- /dev/null +++ b/scripts/client-address-isolation-test-env.mjs @@ -0,0 +1,134 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { createHash } from 'node:crypto' +import { executionOptions } from './client-workbench-test-env.mjs' +import { customerProfilePath } from './client-import-template-test-env.mjs' + +const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +class CheckFailure extends Error {} +function requireFact(ok, name) { if (!ok) throw new CheckFailure(name) } + +export function addressDraft(kind, address, warehouse, channel, destination) { + return { customerName: '地址隔离验收', transportMode: 'SEA', tradeTerm: 'FOB', destinationCountryCode: 'US', + receivingWarehouseId: warehouse.id, receivingWarehouseName: warehouse.warehouseName, + channelCode: channel.channelCode, channelName: channel.channelName, taxMode: 'BUY_DECLARATION', + pricingTaxMode: 'TAX_INCLUDED', containerMode: 'LCL', customsInspection: false, fulfillmentType: 'STANDARD', + destinationType: kind === 'PICKUP' ? 'FBA' : 'PRIVATE', pickupMethod: kind === 'PICKUP' ? 'PICKUP' : 'SELF_TRUCK', + ...(kind === 'PICKUP' ? { senderAddressId: address.id, destinationWarehouseId: destination.id, + destinationWarehouseName: destination.warehouseName, amazonWarehouseCode: destination.warehouseCode } + : { privateDestinationAddressId: address.id }), + cargoes: [{ cargoName: '地址边界测试货物', packageType: 'CARTON', packageCount: 1, grossWeightKg: 1, + volumeCbm: 0.001, unitsPerPackage: 1, totalQuantity: 1, woodenProduct: false, dangerousGoods: false }] } +} + +export function assertAddressRejected(response, batch = false) { + const body = response.body + if (batch) { + requireFact(response.http === 200 && String(body?.code) === '200', 'Batch transport failed before row-level address validation') + const data = body.data + requireFact(data?.total === 1 && data.succeeded === 0 && data.failed === 1 && data.items?.length === 1, 'Unexpected batch success or shape') + const row = data.items[0] + requireFact(row.success === false && row.orderId == null && row.errorCode === 'A0404' + && row.errorMessage === '客户地址不存在或已停用', 'Batch did not reject specifically at the customer-address boundary') + } else { + requireFact([200, 404].includes(response.http) && body?.code === 'A0404' + && body.message === '客户地址不存在或已停用' && body.data == null, 'Create did not reject specifically at the customer-address boundary') + } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, + requests: 'Two independent logins; existing source addresses; two create and two single-row batch rejection probes; logout both sessions', + writes: 'Negative creation probes must return the precise address-not-found error; stop immediately on unexpected success or unknown outcome' } + requireFact(args.length === 4, 'Explicit TEST run and customer profile required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const profilePath = customerProfilePath(args[3], execArgs[0].slice(11)) + const control = parseEnv(await readFile(profilePath, 'utf8')) + requireFact(control.MERCHANT_TEST_USERNAME && control.MERCHANT_TEST_PASSWORD, 'Missing control credentials') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const directory = resolve(root, 'reports', `client-address-isolation-${options.runId}`) + await mkdir(directory) + const report = { startedAt: new Date().toISOString(), success: false, requests: [], checks: [], sessionsClosed: [] } + const sessions = [] + const save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(report, null, 2)) + async function request(session, method, path, body, key) { + requireFact(method === 'GET' || ['/auth/login', '/auth/logout', '/merchant/v1/orders/first-leg', '/merchant/v1/orders/first-leg/batch'].includes(path), 'Unexpected write endpoint') + const entry = { session: session.name, method, path: path.split('?')[0], outcome: 'PENDING', key, + payloadSha256: body && !path.startsWith('/auth/') ? digest(body) : undefined } + report.requests.push(entry); await save() + try { + const result = await fetch(options.base + path, { method, signal: AbortSignal.timeout(30000), redirect: 'error', + headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(session.token ? { Authorization: `Bearer ${session.token}` } : {}), ...(key ? { 'Idempotency-Key': key } : {}) }, body: body ? JSON.stringify(body) : undefined }) + const payload = await result.json() + Object.assign(entry, { outcome: 'RECEIVED', http: result.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId }) + if (method === 'POST' && path.startsWith('/merchant/v1/orders/first-leg')) { + entry.returnedOrderIds = Array.isArray(payload.data?.items) ? payload.data.items.filter(r => r.orderId != null).map(r => String(r.orderId)) + : typeof payload.data === 'string' || typeof payload.data === 'number' ? [String(payload.data)] : [] + entry.rowErrorCodes = payload.data?.items?.map(r => r.errorCode) + } + await save() + return { http: result.status, body: payload } + } catch (error) { entry.outcome = 'UNKNOWN_STOP'; await save(); throw error } + } + async function successful(session, method, path, body) { + const response = await request(session, method, path, body) + requireFact(response.http === 200 && response.body?.code === '200', 'Setup or cleanup request rejected') + return response.body.data + } + try { + for (const [name, username, password] of [['source', env.CHONGCHONG_MERCHANT_USERNAME, env.CHONGCHONG_MERCHANT_PASSWORD], + ['control', control.MERCHANT_TEST_USERNAME, control.MERCHANT_TEST_PASSWORD]]) { + const session = { name }; sessions.push(session) + const auth = await successful(session, 'POST', '/auth/login', { loginId: username, password }) + session.token = auth?.accessToken; session.refresh = auth?.refreshToken + requireFact(session.token && session.refresh, 'Incomplete login') + session.profile = await successful(session, 'GET', '/merchant/v1/iam/tenant/profile') + requireFact(session.profile?.id != null, 'Missing authenticated customer identity') + } + const [source, target] = sessions + requireFact(String(source.profile.id) !== String(target.profile.id), 'Two different customers required') + report.customerIdentitySha256 = sessions.map(s => digest(String(s.profile.id))) + const warehouses = await successful(target, 'GET', '/merchant/v1/warehouses?warehouseType=DOMESTIC&status=ACTIVE&page=1&size=200') + const warehouse = warehouses.records.find(w => w.warehouseType === 'DOMESTIC' && w.status === 'ACTIVE') + requireFact(warehouse, 'A real active domestic warehouse is required') + const channels = await successful(target, 'GET', `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`) + requireFact(channels.length > 0, 'A real channel is required') + const destinationPage = await successful(target, 'GET', '/merchant/v1/warehouses?warehouseType=FBA&status=ACTIVE&page=1&size=200') + const destination = destinationPage.records.find(w => w.warehouseType === 'FBA' && w.status === 'ACTIVE') + requireFact(destination, 'A real active FBA destination is required') + for (const kind of ['PICKUP', 'PRIVATE_DESTINATION']) { + const path = `/merchant/v1/orders/first-leg/addresses?addressType=${kind}` + const foreign = await successful(source, 'GET', path), own = await successful(target, 'GET', path) + requireFact(foreign.length > 0, 'Source address sample missing') + requireFact(!own.some(a => String(a.id) === String(foreign[0].id)), 'Source address leaked into target directory') + const draft = addressDraft(kind, foreign[0], warehouse, channels[0], destination) + for (const batch of [false, true]) { + const key = `ADDR-${options.runId}-${kind}-${batch ? 'batch' : 'single'}` + const body = batch ? [{ clientRowId: kind, draft }] : draft + const response = await request(target, 'POST', '/merchant/v1/orders/first-leg' + (batch ? '/batch' : ''), body, key) + assertAddressRejected(response, batch) + report.checks.push({ kind, endpoint: batch ? 'batch' : 'single', passed: true, payloadSha256: digest(body) }) + await save() + } + } + report.success = true + report.limitation = 'Proves JSON single/batch address rejection; XLSX upload parsing and database zero-drift evidence remain separate' + } catch (error) { + report.failure = error instanceof CheckFailure ? error.message : 'Transport, setup or response format failure; no automatic retry' + } finally { + for (const session of sessions) if (session.token) { + try { await successful(session, 'POST', '/auth/logout', { refreshToken: session.refresh }); report.sessionsClosed.push(session.name) } + catch { report.success = false; report.logoutFailure = true } + } + report.success = report.success && report.sessionsClosed.length === 2 + report.completedAt = new Date().toISOString(); await save() + } + return { success: report.success, checks: report.checks.length, report: resolve(directory, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Address isolation probe did not start; check explicit TEST profile and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-address-isolation-test-env.test.mjs b/scripts/client-address-isolation-test-env.test.mjs new file mode 100644 index 000000000..62b95626a --- /dev/null +++ b/scripts/client-address-isolation-test-env.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { addressDraft, assertAddressRejected, execute } from './client-address-isolation-test-env.mjs' + +test('negative probe plan does not require credentials or execute HTTP', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) +}) +test('each payload probes exactly one foreign address field', () => { + const args = [{ id: 'foreign' }, { id: 'domestic', warehouseName: '国内仓' }, { channelCode: 'SEA', channelName: '海运' }, { id: 'fba', warehouseCode: 'FBA-A', warehouseName: '目的仓' }] + const pickup = addressDraft('PICKUP', ...args), destination = addressDraft('PRIVATE_DESTINATION', ...args) + assert.equal(pickup.senderAddressId, 'foreign'); assert.equal(pickup.privateDestinationAddressId, undefined) + assert.equal(pickup.destinationType, 'FBA'); assert.equal(pickup.destinationWarehouseId, 'fba') + assert.equal(destination.privateDestinationAddressId, 'foreign'); assert.equal(destination.senderAddressId, undefined) + assert.equal(destination.destinationType, 'PRIVATE'); assert.equal(destination.pickupMethod, 'SELF_TRUCK') +}) +test('accepts only the precise address-boundary rejection, never unrelated validation or success', () => { + const single = { http: 200, body: { code: 'A0404', message: '客户地址不存在或已停用', data: null } } + const item = { success: false, orderId: null, errorCode: 'A0404', errorMessage: '客户地址不存在或已停用' } + const batch = { http: 200, body: { code: '200', data: { total: 1, succeeded: 0, failed: 1, items: [item] } } } + assert.doesNotThrow(() => assertAddressRejected(single)) + assert.doesNotThrow(() => assertAddressRejected(batch, true)) + for (const code of ['200', 'A0422', 'A0403']) assert.throws(() => assertAddressRejected({ ...single, body: { ...single.body, code } })) + assert.throws(() => assertAddressRejected({ ...single, body: { ...single.body, message: '仓库不存在' } })) + for (const change of [{ success: true, orderId: 'created' }, { errorCode: 'A0422' }, { errorMessage: '字段不能为空' }]) { + assert.throws(() => assertAddressRejected({ ...batch, body: { code: '200', data: { ...batch.body.data, items: [{ ...item, ...change }] } } }, true)) + } +}) -- 2.54.0 From 8700fc34238bc33339351c73c15f3af8de8ce517 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 16:06:54 +0800 Subject: [PATCH 104/176] feat(lastleg): redispatch returned cargo and reconcile source signatures --- deploy/sql/README.md | 5 +- deploy/sql/海外区域统一增量-MySQL8.sql | 37 ++- lastleg-service/AGENTS.md | 2 +- .../mapper/DeliverySourceEvidenceMapper.java | 59 +++++ .../model/SourceDeliveryCompletionPolicy.java | 134 ++++++++++ .../DeliveryCompletedOutboxService.java | 35 ++- .../service/DeliveryDocumentService.java | 79 +++--- .../SourceDeliveryCompletionQuery.java | 84 +++++++ .../controller/SmartDispatchController.java | 6 +- .../dispatch/dto/DispatchRequests.java | 22 +- .../lastleg/dispatch/entity/DispatchItem.java | 1 + .../dispatch/entity/DispatchPalletLock.java | 1 + .../mapper/ReturnDispatchCandidateMapper.java | 44 ++++ .../model/DispatchPalletCandidate.java | 117 ++++++--- .../pda/entity/DispatchPdaPickOrder.java | 1 + .../service/DispatchPdaWorkflowService.java | 4 +- .../DispatchPickOrderAttributionService.java | 45 +++- .../dispatch/service/DispatchService.java | 63 +++-- .../service/InventoryCommandService.java | 22 ++ .../inventory/entity/ReturnStockReceipt.java | 27 +++ .../mapper/ReturnStockReceiptMapper.java | 34 +++ .../lastleg/mapper/LastlegOrderMapper.java | 12 +- .../src/main/resources/db/lastleg.sql | 35 ++- .../openapi/overseas-business-v1.yaml | 13 +- .../DeliverySourceEvidenceMapperTest.java | 60 +++++ ...iverySourcePublicationPersistenceTest.java | 228 ++++++++++++++++++ ...urceDeliveryCompletionConcurrencyTest.java | 82 +++++++ .../SourceDeliveryCompletionPolicyTest.java | 91 +++++++ .../SourceDeliveryCompletionQueryTest.java | 98 ++++++++ .../DeliveryCompletedOutboxServiceTest.java | 18 +- .../service/DeliveryDocumentServiceTest.java | 24 +- .../SmartDispatchControllerTest.java | 2 +- ...spatchPickOrderAttributionServiceTest.java | 48 ++++ ...urnedDispatchAllowancePersistenceTest.java | 67 +++++ .../dispatch/service/DispatchServiceTest.java | 54 +++++ ...eturnDispatchCandidatePersistenceTest.java | 55 +++++ .../command/InventoryCommandServiceTest.java | 11 + ...ventoryHoldConcurrencyPersistenceTest.java | 7 +- .../initialization-baseline-contract.test.mjs | 17 +- 39 files changed, 1605 insertions(+), 139 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b17f7ea9a..b5ddec95d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 152 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 153 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -16,6 +16,7 @@ - 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。 - 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。 - 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。 +- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -28,7 +29,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/152`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/153`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 02edb3802..459cc0374 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -376,6 +376,8 @@ CREATE TABLE ll_dispatch_item ( source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), KEY idx_ll_dispatch_item_order (customer_id,order_id), @@ -433,6 +435,8 @@ CREATE TABLE ll_dispatch_pallet_lock ( released_at datetime(3) DEFAULT NULL COMMENT '释放时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), @@ -470,15 +474,44 @@ CREATE TABLE ll_dispatch_pda_pick ( KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货记录'; +CREATE TABLE ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; + CREATE TABLE ll_dispatch_pda_pick_order ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id bigint NOT NULL COMMENT '入仓实收记录ID', + receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id bigint NOT NULL COMMENT '订单ID', packages bigint NOT NULL COMMENT '本次操作箱数', product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), + + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) @@ -3392,4 +3425,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 152 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 153 AS initialized_tables; diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d2f0489c0..3ce3b32a3 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -106,7 +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_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_*` | 托盘/箱唛库存、流水和废弃审批 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java new file mode 100644 index 000000000..d83da8796 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** Use SourceDeliveryCompletionQuery in an explicit READ_COMMITTED write transaction. + * lockSource is a current locking read; history queries deliberately do not lock delivery rows. + * No pagination or current ll_order.delivery_order_no association is used. + */ +@Mapper +public interface DeliverySourceEvidenceMapper { + @Select("SELECT source_inbound_id, id AS order_id, package_count AS packages FROM ll_order " + + "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND source_inbound_id=#{sourceInboundId}") + Source source(@Param("customerId") String customerId, @Param("sourceInboundId") String sourceInboundId); + + @Select("SELECT source_inbound_id,id AS order_id,package_count AS packages FROM ll_order " + + "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE") + Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId); + + @Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " + + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + + "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id") + List dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId); + + @Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," + + "d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " + + "LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " + + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id") + List deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + + @Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," + + "CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " + + "THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " + + "WHEN COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')) IS NOT NULL " + + "THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')))) " + + "WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," + + "a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " + + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + + "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " + + "LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " + + "AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " + + "AND re.id=r.exception_order_id AND re.exception_no=rl.business_order_no AND re.last_mile_order_id=a.order_id " + + "WHERE rl.customer_id=r.customer_id AND rl.id=r.inventory_ledger_id AND rl.business_type='RETURN_STOCK_IN' " + + "AND rl.outbound_order_no=r.source_outbound_no AND rl.delta_packages=r.received_packages " + + "AND rl.source_event_id=CONCAT('RETURN_SOURCE:',r.source_outbound_pallet_item_id,':',r.source_inventory_position_id)) " + + "WHERE a.customer_id=#{customerId} AND t.dispatch_order_id=#{dispatchId} ORDER BY a.id") + List picks(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + + @Select("SELECT l.id AS ledger_id,d.id AS delivery_id,e.last_mile_order_id AS order_id," + + "l.delta_packages AS packages,l.occurred_at AS received_at FROM ll_delivery_order d " + + "JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + + "JOIN ll_inventory_ledger l ON l.customer_id=e.customer_id AND l.business_order_no=e.exception_no " + + "AND l.outbound_order_no=d.outbound_no AND l.business_type='RETURN_STOCK_IN' AND l.delta_packages>0 " + + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY l.id") + List returns(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java new file mode 100644 index 000000000..fa5255785 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java @@ -0,0 +1,134 @@ +package com.baiyue.lastleg.delivery.model; + +import java.time.LocalDateTime; +import java.util.*; + +/** Read-only proof of whole-source signature. Inputs must cover ALL history for the requested source. */ +public final class SourceDeliveryCompletionPolicy { + private SourceDeliveryCompletionPolicy() { } + public record Source(String sourceInboundId, long orderId, long packages) { } + public record Delivery(long deliveryId, long dispatchId, String groupKey, long packages, + Long signedPackages, LocalDateTime signedAt, LocalDateTime departedAt) { } + public record Pick(long attributionId, long dispatchId, String groupKey, long orderId, long packages, + Long receiptId, Long returnReceiptId, Long returnLedgerId, Long returnPackages) { } + public record Returned(long ledgerId, long deliveryId, long orderId, long packages, LocalDateTime receivedAt) { } + public record Result(String sourceInboundId, long originalPackages, long provenSignedPackages, + boolean fullySigned, List unresolvedReasons) { } + + public static Result evaluate(Source source, List deliveries, List picks, + List returns, boolean completeHistory) { + Objects.requireNonNull(source); + var reasons = new LinkedHashSet(); + if (!completeHistory) reasons.add("HISTORY_NOT_COMPLETE"); + if (source.sourceInboundId() == null || source.sourceInboundId().isBlank() || source.packages() <= 0) + reasons.add("SOURCE_QUANTITY_MISSING"); + // Only destinations containing this source can affect its proof. Keep co-shipped sources + // within those destinations to reconcile the exact delivery signature total. + var relevant = deliveries.stream().filter(d -> picks.stream().anyMatch(p -> p.orderId() == source.orderId() + && p.dispatchId() == d.dispatchId() && Objects.equals(p.groupKey(),d.groupKey()))).toList(); + var relevantIds = new HashSet(); + for (var d : relevant) if (!relevantIds.add(d.deliveryId())) reasons.add("DUPLICATE_DELIVERY"); + var attributionIds = new HashSet(); var ledgerIds = new HashSet(); + for (var p : picks) if (relevant.stream().anyMatch(d -> d.dispatchId()==p.dispatchId() && Objects.equals(d.groupKey(),p.groupKey()))) { + if (!attributionIds.add(p.attributionId()) || p.packages() <= 0 || p.groupKey() == null) reasons.add("INVALID_PICK_ATTRIBUTION"); + if ((p.receiptId() == null) == (p.returnReceiptId() == null) + || (p.receiptId()!=null && p.receiptId()<=0)) reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); + } + for (var r : returns) if (relevantIds.contains(r.deliveryId()) && (!ledgerIds.add(r.ledgerId()) + || r.packages()<=0 || r.receivedAt()==null)) reasons.add("INVALID_RETURN_LEDGER"); + long signed = 0; + for (var d : relevant) { + var matching = picks.stream().filter(p -> p.dispatchId() == d.dispatchId() && Objects.equals(p.groupKey(), d.groupKey())).toList(); + var quantities = new HashMap(); + for (var p : matching) quantities.merge(p.orderId(), p.packages(), Math::addExact); + if (d.groupKey() == null || d.packages() <= 0 || d.departedAt() == null + || matching.stream().mapToLong(Pick::packages).sum() != d.packages() + || deliveries.stream().filter(other -> other.dispatchId() == d.dispatchId() && Objects.equals(other.groupKey(), d.groupKey())).count() != 1) { + reasons.add("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:" + d.deliveryId()); continue; + } + if (d.signedAt() == null) { + if (d.signedPackages() != null) reasons.add("SIGNATURE_TIME_MISSING:" + d.deliveryId()); + continue; + } + if (d.signedAt().isBefore(d.departedAt()) || d.signedPackages() == null || d.signedPackages() <= 0 || d.signedPackages() > d.packages()) { + reasons.add("SIGNATURE_QUANTITY_UNRESOLVED:" + d.deliveryId()); continue; + } + var before = new HashMap(); boolean valid = true; + for (var r : returns) if (r.deliveryId() == d.deliveryId()) { + if (r.receivedAt() == null || r.receivedAt().isBefore(d.departedAt()) || !quantities.containsKey(r.orderId())) { + valid = false; break; + } + // Millisecond-equal timestamps cannot prove which transaction happened first. + if (r.receivedAt().equals(d.signedAt())) { valid = false; break; } + if (r.receivedAt().isBefore(d.signedAt())) before.merge(r.orderId(), r.packages(), Math::addExact); + } + if (!valid || before.entrySet().stream().anyMatch(e -> e.getValue() > quantities.getOrDefault(e.getKey(), 0L)) + || d.packages() - before.values().stream().mapToLong(Long::longValue).sum() != d.signedPackages()) { + reasons.add("PRE_SIGNATURE_RETURN_UNRESOLVED:" + d.deliveryId()); continue; + } + long fresh = 0, previouslySigned = 0; boolean lineageValid = true; + for (var p : matching) if (p.orderId() == source.orderId()) { + Boolean eligible = freshOrigin(p,d,deliveries,picks,returns,new HashSet<>(),reasons); + if (eligible == null) lineageValid = false; + else if (eligible) fresh = Math.addExact(fresh,p.packages()); + else previouslySigned = Math.addExact(previouslySigned,p.packages()); + } + long returnedBefore = before.getOrDefault(source.orderId(),0L); + if (fresh>0 && previouslySigned>0 && returnedBefore>0) { + reasons.add("MIXED_RESIGNED_RETURN_UNRESOLVED:"+d.deliveryId()); lineageValid=false; + } + if (lineageValid) signed = Math.addExact(signed,fresh==0?0:fresh-returnedBefore); + } + for (var p : picks) if (p.orderId() == source.orderId() && deliveries.stream().noneMatch(d -> + d.dispatchId() == p.dispatchId() && Objects.equals(d.groupKey(), p.groupKey()))) reasons.add("PICK_DELIVERY_MISSING"); + // Repeated post-signature shipments cannot be treated as distinct originally loaded boxes. + if (signed > source.packages()) reasons.add("SIGNED_EXCEEDS_ORIGINAL_SOURCE"); + return new Result(source.sourceInboundId(), source.packages(), signed, + reasons.isEmpty() && signed == source.packages(), List.copyOf(reasons)); + } + /** Receipt identity is exact; package identity inside mixed fresh/already-signed cohorts is not. */ + private static Boolean freshOrigin(Pick pick, Delivery current, List deliveries, List picks, + List returns, Set visiting, Set reasons) { + if (!visiting.add(pick.attributionId())) { reasons.add("RETURN_LINEAGE_CYCLE"); return null; } + try { + if ((pick.receiptId()==null)==(pick.returnReceiptId()==null)) { reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); return null; } + if (pick.receiptId()!=null) { + if(pick.receiptId()<=0) { reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); return null; } + return true; + } + var receipts=returns.stream().filter(r->Objects.equals(pick.returnLedgerId(),r.ledgerId())).toList(); + if(pick.returnReceiptId()<=0 || receipts.size()!=1 || pick.returnPackages()==null + || receipts.get(0).orderId()!=pick.orderId() || receipts.get(0).packages()!=pick.returnPackages() + || picks.stream().filter(p->Objects.equals(p.returnLedgerId(),pick.returnLedgerId())).mapToLong(Pick::packages).sum()>pick.returnPackages()) { + reasons.add("RETURN_PICK_ORIGIN_UNRESOLVED:"+pick.attributionId()); return null; + } + var receipt=receipts.get(0); + var parents=deliveries.stream().filter(d->d.deliveryId()==receipt.deliveryId()).toList(); + if(parents.size()!=1 || receipt.receivedAt()==null || current.departedAt()==null + || !receipt.receivedAt().isBefore(current.departedAt())) { + reasons.add("RETURN_PICK_CHRONOLOGY_UNRESOLVED:"+pick.attributionId());return null; + } + var parent=parents.get(0); + if(parent.departedAt()==null || receipt.receivedAt().isBefore(parent.departedAt()) + || (parent.signedAt()!=null && receipt.receivedAt().equals(parent.signedAt()))) { + reasons.add("RETURN_PICK_LINEAGE_UNRESOLVED:"+pick.attributionId()); return null; + } + var parentPicks=picks.stream().filter(p->p.dispatchId()==parent.dispatchId() + && Objects.equals(p.groupKey(),parent.groupKey()) && p.orderId()==pick.orderId()).toList(); + if(parentPicks.isEmpty() || receipt.packages()>parentPicks.stream().mapToLong(Pick::packages).sum()) { + reasons.add("RETURN_PICK_ORIGIN_UNRESOLVED:"+pick.attributionId());return null; + } + // After a validated signature every box remaining in that movement is already signed. + if(parent.signedAt()!=null && parent.signedAt().isBefore(receipt.receivedAt())) return false; + Boolean eligibility=null; + for(var ancestor:parentPicks) { + Boolean value=freshOrigin(ancestor,parent,deliveries,picks,returns,visiting,reasons); + if(value==null)return null; + if(eligibility!=null && !eligibility.equals(value)) { reasons.add("RETURN_LINEAGE_MIXED_COHORT");return null; } + eligibility=value; + } + return eligibility; + } finally { visiting.remove(pick.attributionId()); } + } + +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 954dd8d36..10fc42300 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -35,25 +35,14 @@ public class DeliveryCompletedOutboxService { public enum Publication { PUBLISHED, NO_DOMESTIC_SOURCE, UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION, MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION } - public Publication publish(DeliveryOrder delivery, List orders, Instant completedAt) { + public Publication publish(DeliveryOrder delivery, List orders, Map proofs, Instant completedAt) { Objects.requireNonNull(delivery, "delivery"); Objects.requireNonNull(completedAt, "completedAt"); // The domestic consumer marks the whole inbound order DELIVERED (signed), not merely transport closed. // A mixed return/signature must not publish that full-signature fact. Map bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.of() : orders) { - // Only orders projected from the domestic first-leg domain need an - // acknowledgement event. Customer/local overseas orders can still carry - // a local sourceInboundId, but deliberately have no domestic sourceOrderId. - if (StringUtils.hasText(order.getSourceRegion()) - && !"FIRSTLEG".equals(order.getSourceRegion())) continue; - // Relabel and warehouse-transfer deliveries are created and completed wholly - // in the overseas last-leg domain. They do not have a domestic source order - // to acknowledge, so publishing the cross-region event would make receipt - // completion fail on the missing source-order fields. - if ("RELABEL_APPLICATION".equals(order.getOrderType()) - || "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue; - if (!StringUtils.hasText(order.getSourceInboundId())) continue; + if (!domestic(order)) continue; requireText(order.getSourceOrderId(), "sourceOrderId"); requireText(order.getInboundCode(), "inboundCode"); requireText(order.getCustomerId(), "customerId"); @@ -67,15 +56,25 @@ public class DeliveryCompletedOutboxService { if (delivery.getPackageCount() == null || delivery.getPackageCount() <= 0 || delivery.getSignedPackages() == null || delivery.getSignedPackages() <= 0 || delivery.getSignedPackages() > delivery.getPackageCount()) return Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION; - if (!Objects.equals(delivery.getSignedPackages(), delivery.getPackageCount())) - return Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION; - for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt); - return Publication.PUBLISHED; + boolean pending = false; + for (LastlegOrder order : bySourceInbound.values()) { + var proof = proofs.get(order.getSourceInboundId()); + if (proof == null || !proof.fullySigned() || !proof.unresolvedReasons().isEmpty() + || !Objects.equals(proof.sourceInboundId(),order.getSourceInboundId()) + || order.getPackageCount() == null || proof.originalPackages() != order.getPackageCount() + || proof.provenSignedPackages() != proof.originalPackages()) { pending = true; continue; } + publishOne(delivery, order, completedAt); + } + return pending ? Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION : Publication.PUBLISHED; + } + static boolean domestic(LastlegOrder order) { + return "FIRSTLEG".equals(order.getSourceRegion()) && StringUtils.hasText(order.getSourceInboundId()) + && !"RELABEL_APPLICATION".equals(order.getOrderType()) && !"WAREHOUSE_TRANSFER".equals(order.getOrderType()); } private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) { long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion()); - String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo() + String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + order.getCustomerId() + ':' + order.getSourceInboundId(); var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(), String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 295c44f16..d774daa34 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -35,6 +35,8 @@ public class DeliveryDocumentService { @Resource private LastlegOrderMapper lastlegOrders; @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; @Resource private CustomerVisitService customerVisits; + @Resource private SourceDeliveryCompletionQuery sourceCompletion; + @Resource private DeliverySourceEvidenceMapper sourceEvidence; public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } @@ -56,7 +58,7 @@ public class DeliveryDocumentService { } /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); if (order == null) throw missing(); @@ -110,19 +112,8 @@ public class DeliveryDocumentService { } orders.updateById(order); if (autoCompleted) { - List completedOrders = completeRelatedOrders(order, now); - var publication = deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); - if (publication == DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION - || publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION) { - DeliveryOperation pending = new DeliveryOperation(); - pending.setCustomerId(customerId); pending.setDeliveryOrderId(order.getId()); pending.setOperatorId(operatorId); - pending.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); - pending.setResult(publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION - ? "签收数量未确认或无效,国内整票签收事件未发布,须核对真实实签量后继续回写" - : "原实发" + order.getPackageCount() + "箱,本次签收" + order.getSignedPackages() - + "箱,其余已退回;国内整票签收事件未发布,须按来源入仓单核对签收与退回处置后继续回写"); - pending.setCreatedAt(now); operations.insert(pending); - } + var completion = completeRelatedOrders(order, operatorId, now); + deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.toInstant(ZoneOffset.UTC)); customerVisits.ensureForCompletedDelivery(order, now); } Detail result = detail(order, false); @@ -166,20 +157,54 @@ public class DeliveryDocumentService { (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } - private List completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) { - List completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) - .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); - for (LastlegOrder order : completedOrders) { - // Preserve the outbound leg when receipt completion closes delivery. - // Sub-order status is a composite fact; replacing it with DELIVERY alone - // makes a successfully departed outbound appear unfinished or disappear. - order.setSubOrderStatus(delivery.getSignedPackages().equals(delivery.getPackageCount()) - ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); - order.setUpdatedAt(now); - lastlegOrders.updateById(order); + private record Completion(List orders, Map proofs) { } + private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) { + var related = new TreeMap(); + if (delivery.getDispatchOrderId() != null) { + var ids = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() + .filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())) + .map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList(); + if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) { + if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致"); + related.put(tail.getId(), tail); + } } - return completedOrders; + // Non-dispatch local workflows retain their established relation; never use this to prove a domestic source. + for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) { + if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail); + else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now, + inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件"); + } + var sourceIds = related.values().stream().filter(DeliveryCompletedOutboxService::domestic) + .map(LastlegOrder::getSourceInboundId).distinct().toList(); + var proofs = sourceIds.isEmpty() ? Map.of() + : sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds); + // All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots. + var current = new ArrayList(); + for (long id : related.keySet()) { + var tail = lastlegOrders.selectById(id); + if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化"); + if (DeliveryCompletedOutboxService.domestic(tail)) { + var proof = proofs.get(tail.getSourceInboundId()); + if (proof != null && proof.fullySigned()) tail.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED"); + else { + tail.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY"); + pendingCompletion(delivery, operatorId, now, inboundLabel(tail) + ":来源签收未核齐;" + + (proof == null ? "缺少来源证明" : "已证明" + proof.provenSignedPackages() + "/" + proof.originalPackages() + "箱;" + (proof.unresolvedReasons().isEmpty() ? "仍有货物未完成签收" : "签收与退回来源证据尚未核齐,请核对实发、实收及签收记录"))); + } + } else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount()) + ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); + tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail); + } + return new Completion(List.copyOf(current),proofs); + } + private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); } + private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) { + var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId()); + operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId); + operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason); + operation.setCreatedAt(now); operations.insert(operation); } private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java new file mode 100644 index 000000000..0798e5a1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.*; + +/** Caller locks its own delivery, then calls proveAll BEFORE any source-order updates. + * Explicit READ_COMMITTED is required. Lock source orders in numeric ID order until commit; + * never lock historical delivery/dispatch/exception/inventory rows from this query. + * RC evidence reads see the preceding source-lock owner's committed signatures without waiting + * for another transaction's delivery lock. All signature writers must follow this protocol. + */ +@Service +public class SourceDeliveryCompletionQuery { + private final DeliverySourceEvidenceMapper evidence; + public SourceDeliveryCompletionQuery(DeliverySourceEvidenceMapper evidence) { this.evidence = evidence; } + + /** Source uses UNIQUE(customer_id,source_region,source_inbound_id); mapper selectOne has no LIMIT. + * A broken uniqueness invariant therefore raises TooManyResultsException, never picks one row. + */ + @Transactional(propagation = Propagation.MANDATORY) + public Result prove(String customerId, String sourceInboundId) { + return proveAll(customerId, List.of(sourceInboundId)).get(sourceInboundId); + } + + /** One call with ALL affected sources; do not loop prove() for a multi-source delivery. */ + @Transactional(propagation = Propagation.MANDATORY) + public Map proveAll(String customerId, Collection sourceInboundIds) { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || TransactionSynchronizationManager.isCurrentTransactionReadOnly()) + throw new IllegalStateException("Source signature proof requires the caller's active write transaction"); + if (!Objects.equals(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel(), TransactionDefinition.ISOLATION_READ_COMMITTED)) + throw new IllegalStateException("Source signature proof requires explicit READ_COMMITTED on the outer transaction"); + if (customerId == null || customerId.isBlank() || sourceInboundIds == null || sourceInboundIds.isEmpty() + || sourceInboundIds.stream().anyMatch(id -> id == null || id.isBlank())) + throw new IllegalArgumentException("Customer and source inbound ID are required"); + var result = new LinkedHashMap(); var sources = new TreeMap(); + for (String id : new TreeSet<>(sourceInboundIds)) { + Source source = evidence.source(customerId, id); + if (source == null) { result.put(id, new Result(id,0,0,false,List.of("SOURCE_NOT_FOUND","HISTORY_NOT_COMPLETE"))); continue; } + if (!id.equals(source.sourceInboundId()) || source.orderId() <= 0 || sources.putIfAbsent(source.orderId(),source) != null) + throw new IllegalStateException("Source evidence identity mismatch"); + } + var locked = new ArrayList(); + for (var source : sources.values()) { + Source current = evidence.lockSource(customerId, source.orderId()); + if (current == null || current.orderId() != source.orderId() || !current.sourceInboundId().equals(source.sourceInboundId())) + throw new IllegalStateException("Source changed while acquiring completion lock"); + locked.add(current); + } + for (var source : locked) result.put(source.sourceInboundId(), proveLocked(customerId, source)); + return Collections.unmodifiableMap(result); + } + + private Result proveLocked(String customerId, Source source) { + String sourceInboundId = source.sourceInboundId(); + var ids = Objects.requireNonNull(evidence.dispatchIds(customerId, source.orderId()), "Dispatch history missing"); + if (ids.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(ids).size() != ids.size()) + throw new IllegalStateException("Invalid or duplicate historical dispatch IDs"); + var deliveries = new ArrayList(); var picks = new ArrayList(); var returns = new ArrayList(); + for (long id : ids.stream().sorted().toList()) { + var dispatchDeliveries = Objects.requireNonNull(evidence.deliveries(customerId, id), "Delivery evidence missing"); + var dispatchPicks = Objects.requireNonNull(evidence.picks(customerId, id), "Pick evidence missing"); + var dispatchReturns = Objects.requireNonNull(evidence.returns(customerId, id), "Return evidence missing"); + if (dispatchDeliveries.isEmpty() || dispatchPicks.isEmpty() + || dispatchDeliveries.stream().anyMatch(row -> row == null || row.dispatchId() != id) + || dispatchPicks.stream().anyMatch(row -> row == null || row.dispatchId() != id) + || dispatchPicks.stream().noneMatch(row -> row.orderId() == source.orderId())) + return new Result(sourceInboundId, source.packages(), 0, false, + List.of("DISPATCH_HISTORY_INCOMPLETE:" + id, "HISTORY_NOT_COMPLETE")); + var deliveryIds = new HashSet(); dispatchDeliveries.forEach(row -> deliveryIds.add(row.deliveryId())); + if (dispatchReturns.stream().anyMatch(row -> row == null || !deliveryIds.contains(row.deliveryId()))) + throw new IllegalStateException("Return evidence belongs to another dispatch"); + deliveries.addAll(dispatchDeliveries); picks.addAll(dispatchPicks); returns.addAll(dispatchReturns); + } + return SourceDeliveryCompletionPolicy.evaluate(source, deliveries, picks, returns, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 6f111fd19..df110692e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -75,7 +75,7 @@ public class SmartDispatchController { @Valid @RequestBody DispatchRequests.PreArrange request) { requirePlatform(user); String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank() - ? service.resolveCustomerScopeForPallets(request.planPalletIds()) + ? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds()) : service.resolveCustomerScopeForRelabel(request.relabelNo()); String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); @@ -88,7 +88,7 @@ public class SmartDispatchController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DispatchRequests.DraftPallet request) { requirePlatform(user); - String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); service.lockDraftPallet(customerId, operatorId(user), request); return ApiResponse.success(null); } @@ -113,7 +113,7 @@ public class SmartDispatchController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DispatchRequests.DraftPallet request) { requirePlatform(user); - String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); service.unlockDraftPallet(customerId, operatorId(user), request); return ApiResponse.success(null); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 8056cd113..3cdad5daf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -19,12 +19,21 @@ public final class DispatchRequests { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } - public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, + public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, @Size(max = 64) String draftToken, - @Size(max = 64) String relabelNo) { + @Size(max = 64) String relabelNo, + @Size(max = 500) List<@NotNull Long> returnReceiptIds) { + public PreArrange { + planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); + returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); + } + public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, + String vehicleAppointmentStatus, String draftToken, String relabelNo) { + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of()); + } public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus) { this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); @@ -35,8 +44,15 @@ public final class DispatchRequests { } } - public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId) { + public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId, + Long returnReceiptId, String sourceType) { + public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); } public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); } + @jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源") + public boolean isValidSource() { + return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType) + : planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType)); + } } public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index df89323c0..cb8d25f94 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -18,6 +18,7 @@ public class DispatchItem { private String customerId; private Long planId; private Long planPalletId; + private Long returnReceiptId; private Long warehousePalletId; private Long orderId; private String overseasWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index 57239e4ed..eb6728f0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -22,6 +22,7 @@ public class DispatchPalletLock { private String activeKey; private Long planId; private Long planPalletId; + private Long returnReceiptId; private Long warehousePalletId; private Long dispatchOrderId; @TableField(updateStrategy = FieldStrategy.ALWAYS) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java new file mode 100644 index 000000000..74bb166d7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface ReturnDispatchCandidateMapper { + String SELECT = "SELECT r.id AS returnReceiptId,r.receipt_no AS returnReceiptNo,r.order_id AS orderId," + + "r.customer_id AS customerId,o.customer_name AS customerName,o.order_type AS orderType,o.order_no AS orderNo," + + "o.order_no AS orderRefs,b.inbound_code AS inboundCode,p.pallet_id AS warehousePalletId,p.pallet_no AS palletNo," + + "p.overseas_warehouse_id AS overseasWarehouseId,b.overseas_warehouse_name AS overseasWarehouseName," + + "b.overseas_warehouse_code AS overseasWarehouseCode,d.destination_type AS destinationType," + + "d.destination_warehouse_code AS destinationWarehouseCode,d.destination_warehouse_code AS fbaWarehouseCode," + + "d.destination_warehouse_name AS destinationWarehouseName,d.delivery_address AS deliveryAddress,d.postal_code AS postalCode," + + "d.contact_name AS contactName,d.contact_phone AS contactPhone,'RECEIVED' AS sourceStage," + + "LEAST(p.available_packages,r.received_packages-(SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " + + "WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id)) AS packageCount " + + "FROM ll_return_stock_receipt r JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " + + "JOIN ll_inventory_position p ON p.customer_id=r.customer_id AND p.id=r.inventory_position_id " + + "JOIN ll_inventory_balance b ON b.customer_id=r.customer_id AND b.id=r.inventory_balance_id " + + "JOIN ll_exception_order e ON e.customer_id=r.customer_id AND e.id=r.exception_order_id " + + "JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id "; + String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 AND r.received_packages>(SELECT COALESCE(SUM(a.packages),0) " + + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) "; + @Select("") + List available(@Param("customerId") String customerId, @Param("orderNo") String orderNo, + @Param("fba") String fba, @Param("draftToken") String draftToken); + + @Select("") + List lockSelected(@Param("customerId") String customerId, @Param("ids") List ids); + + @Select("") + List customers(@Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index 1fb5f6e9c..9b57005ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -3,42 +3,87 @@ package com.baiyue.lastleg.dispatch.model; import java.math.BigDecimal; import java.time.LocalDateTime; -public record DispatchPalletCandidate( - Long planId, - Long planPalletId, - Long warehousePalletId, - Long orderId, - String customerId, - String customerName, - String planNo, - String orderType, - BigDecimal volumeWeightKg, - BigDecimal grossWeightKg, - String containerNo, - LocalDateTime appointmentAt, - LocalDateTime expectedLoadingAt, - String overseasWarehouseId, - String overseasWarehouseCode, - String overseasWarehouseName, - String orderNo, - String orderRefs, - String inboundCode, - String palletNo, - String destinationWarehouseId, - String destinationWarehouseCode, - String destinationWarehouseName, - String fbaWarehouseCode, - Integer packageCount, - BigDecimal volumeCbm, - String sourceStage, - String cartonMarks, - String relabelCartonMarks, - String destinationType, - String deliveryAddress, - String postalCode, - String contactName, - String contactPhone -) { +@lombok.Data +@lombok.NoArgsConstructor +@lombok.experimental.Accessors(fluent = true) +@com.fasterxml.jackson.annotation.JsonAutoDetect(fieldVisibility = com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY) +public class DispatchPalletCandidate { + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String customerId; + private String customerName; + private String planNo; + private String orderType; + private BigDecimal volumeWeightKg; + private BigDecimal grossWeightKg; + private String containerNo; + private LocalDateTime appointmentAt; + private LocalDateTime expectedLoadingAt; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String orderNo; + private String orderRefs; + private String inboundCode; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String fbaWarehouseCode; + private Integer packageCount; + private BigDecimal volumeCbm; + private String sourceStage; + private String cartonMarks; + private String relabelCartonMarks; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; + private Long returnReceiptId; + private String returnReceiptNo; + @com.fasterxml.jackson.annotation.JsonProperty("sourceType") + public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; } + + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String customerId, String customerName, String planNo, String orderType, BigDecimal volumeWeightKg, BigDecimal grossWeightKg, String containerNo, LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm, String sourceStage, String cartonMarks, String relabelCartonMarks, String destinationType, String deliveryAddress, String postalCode, String contactName, String contactPhone) { + this.planId = planId; + this.planPalletId = planPalletId; + this.warehousePalletId = warehousePalletId; + this.orderId = orderId; + this.customerId = customerId; + this.customerName = customerName; + this.planNo = planNo; + this.orderType = orderType; + this.volumeWeightKg = volumeWeightKg; + this.grossWeightKg = grossWeightKg; + this.containerNo = containerNo; + this.appointmentAt = appointmentAt; + this.expectedLoadingAt = expectedLoadingAt; + this.overseasWarehouseId = overseasWarehouseId; + this.overseasWarehouseCode = overseasWarehouseCode; + this.overseasWarehouseName = overseasWarehouseName; + this.orderNo = orderNo; + this.orderRefs = orderRefs; + this.inboundCode = inboundCode; + this.palletNo = palletNo; + this.destinationWarehouseId = destinationWarehouseId; + this.destinationWarehouseCode = destinationWarehouseCode; + this.destinationWarehouseName = destinationWarehouseName; + this.fbaWarehouseCode = fbaWarehouseCode; + this.packageCount = packageCount; + this.volumeCbm = volumeCbm; + this.sourceStage = sourceStage; + this.cartonMarks = cartonMarks; + this.relabelCartonMarks = relabelCartonMarks; + this.destinationType = destinationType; + this.deliveryAddress = deliveryAddress; + this.postalCode = postalCode; + this.contactName = contactName; + this.contactPhone = contactPhone; + } + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java index 4f87b9f53..c59eb46d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -15,6 +15,7 @@ public class DispatchPdaPickOrder { private String customerId; private Long pickId; private Long receiptId; + private Long returnReceiptId; private Long orderId; private Long packages; private Long productQuantity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 284d35a54..1c64a6bda 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -81,7 +81,7 @@ public class DispatchPdaWorkflowService { return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, String operatorId, String operatorName, DispatchPdaRequests.Pick request) { @@ -147,7 +147,7 @@ public class DispatchPdaWorkflowService { pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); pickAttribution.attribute(customerId, pick, - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); long total = picked + request.packages(); order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index d1207b9cb..3c1bcc9e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -15,7 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; -import java.util.LinkedHashMap; +import java.util.TreeMap; import java.util.List; import java.util.Map; @@ -24,12 +24,14 @@ public class DispatchPickOrderAttributionService { @Resource private DispatchPdaPickOrderMapper attributions; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper history; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; /** Acquire source order locks before any inventory lock, matching order hold/release. */ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void preparePicking(String customerId, long positionId, Long fallbackOrderId) { var sourceIds = new java.util.TreeSet(); attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); + returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId); if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order"); for (Long orderId : sourceIds) { @@ -42,6 +44,10 @@ public class DispatchPickOrderAttributionService { .anyMatch(receipt -> !sourceIds.contains(receipt.getOrderId()))) { throw conflict("PDA pick source orders changed; reload the task"); } + for (var receipt : returnReceipts.available(customerId, positionId)) { + if (!sourceIds.contains(receipt.getOrderId())) throw conflict("Returned source orders changed; reload the task"); + if (returnReceipts.lock(customerId, receipt.getId()) == null) throw conflict("Returned stock receipt disappeared"); + } } /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ @@ -55,14 +61,18 @@ public class DispatchPickOrderAttributionService { * authoritative tail-leg order, so use that order only for relabel picks. */ public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { + return attribute(customerId, pick, fallbackOrderId, null); + } + public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) { long remainingPackages = value(pick.getPackages()); long totalPackages = remainingPackages; long totalQuantity = value(pick.getProductQuantity()); long allocatedPackages = 0; long allocatedQuantity = 0; - Map pickedByOrder = new LinkedHashMap<>(); - for (InboundStockReceipt receipt : attributions.availableReceipts(customerId, - pick.getInventoryPositionId())) { + Map pickedByOrder = new TreeMap<>(); + List pendingAttributions = new java.util.ArrayList<>(); + for (InboundStockReceipt receipt : selectedReturnReceiptId == null ? attributions.availableReceipts(customerId, + pick.getInventoryPositionId()) : List.of()) { if (remainingPackages == 0) break; long receiptPackages = value(receipt.getReceivedPackages()) - attributions.attributedPackages(customerId, receipt.getId()); @@ -80,17 +90,36 @@ public class DispatchPickOrderAttributionService { row.setPackages(packages); row.setProductQuantity(quantity); row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); - attributions.insert(row); + pendingAttributions.add(row); pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum); remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; } - if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) { + for (var receipt : returnReceipts.available(customerId, pick.getInventoryPositionId())) { + if (remainingPackages == 0) break; + if (selectedReturnReceiptId != null && !selectedReturnReceiptId.equals(receipt.getId())) continue; + var locked = returnReceipts.lock(customerId, receipt.getId()); + if (locked == null) throw conflict("Returned stock receipt disappeared"); + long available = locked.getReceivedPackages() - returnReceipts.picked(customerId, locked.getId()); + if (available <= 0) continue; + long packages = Math.min(remainingPackages, available); + long nextPackages = allocatedPackages + packages; + long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); + row.setReturnReceiptId(locked.getId()); row.setOrderId(locked.getOrderId()); row.setPackages(packages); + row.setProductQuantity(nextQuantity - allocatedQuantity); + row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); + pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum); + remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; + } + if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) { pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); remainingPackages = 0; } if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + pendingAttributions.forEach(attributions::insert); for (Map.Entry entry : pickedByOrder.entrySet()) { if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) { throw conflict("Picked packages exceed tail-leg order remaining packages"); @@ -102,7 +131,7 @@ public class DispatchPickOrderAttributionService { /** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */ public List depart(String customerId, String outboundNo, String dispatchNo, List picks, LocalDateTime departedAt) { - Map shippedByOrder = new LinkedHashMap<>(); + Map shippedByOrder = new TreeMap<>(); for (DispatchPdaPick pick : picks) { for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) { shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum); @@ -117,7 +146,7 @@ public class DispatchPickOrderAttributionService { throw conflict("Tail-leg outbound sub-order status could not be synchronized"); } long shipped = value(before.getShippedPackageCount()) + entry.getValue(); - String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; + String to = shipped == value(before.getPackageCount()) + orders.returnedPackages(customerId, entry.getKey()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); row.setCustomerId(customerId); row.setOrderId(entry.getKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index a5ad1de69..80eec715b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -62,6 +62,14 @@ public class DispatchService { @Resource private DispatchDraftMapper drafts; @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; + @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; + + public String resolveCustomerScopeForSources(List planPalletIds, List returnReceiptIds) { + var scopes = new java.util.ArrayList(); + if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds)); + if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds)); + return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources"); + } @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderMapper tailOrders; @@ -120,8 +128,11 @@ public class DispatchService { .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); } } + if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(), + returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList(); return result; } + private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); } public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); } @@ -134,7 +145,7 @@ public class DispatchService { private List containers(String token) { return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), locks.findActiveByDraftContainer(token, c.getId()).stream() - .map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + .map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList(); } @Transactional @@ -177,7 +188,7 @@ public class DispatchService { boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(500); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer); } if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在"); - DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletCandidate candidate = requireDraftSource(customerId, request); LocalDateTime now = LocalDateTime.now(); DispatchPalletLock active = activeLock(customerId, candidate); if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { @@ -199,6 +210,7 @@ public class DispatchService { lock.setActiveKey(candidate.sourcePalletKey()); lock.setPlanId(candidate.planId()); lock.setPlanPalletId(candidate.planPalletId()); + lock.setReturnReceiptId(candidate.returnReceiptId()); lock.setWarehousePalletId(candidate.warehousePalletId()); lock.setDraftToken(request.draftToken()); lock.setDraftContainerId(request.draftContainerId()); @@ -218,7 +230,7 @@ public class DispatchService { @Transactional public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletCandidate candidate = requireDraftSource(customerId, request); DispatchPalletLock active = activeLock(customerId, candidate); if (active == null) return; requireDraftOwner(active, operatorId, request.draftToken()); @@ -226,7 +238,9 @@ public class DispatchService { DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); requirePalletEditingAllowed(dispatch); - DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()); + DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()) + : items.selectOne(new LambdaQueryWrapper().eq(DispatchItem::getCustomerId, customerId) + .eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE")); if (item != null) removeLockedItem(dispatch, item, List.of(active)); else releaseLock(active, LocalDateTime.now()); return; @@ -261,6 +275,7 @@ public class DispatchService { @Transactional public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { if (StringUtils.hasText(request.relabelNo())) { + if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); return preArrangeRelabel(customerId, operatorId, request); } if (!StringUtils.hasText(customerId)) { @@ -268,13 +283,20 @@ public class DispatchService { "No customer business data is available for dispatch"); } List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); - if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { + List receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList(); + if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); + if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size() + || receiptIds.size() != request.returnReceiptIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); } - List selected = candidates.lockSelected(customerId, palletIds); - if (selected.size() != palletIds.size()) { + List selected = new java.util.ArrayList<>(); + if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds)); + if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds)); + if (selected.size() != palletIds.size() + receiptIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size()) + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源"); if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba @@ -338,24 +360,22 @@ public class DispatchService { if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); - Set selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId) + Set selectedPalletIds = selected.stream().map(DispatchService::selectionKey) .collect(Collectors.toSet()); if (refreshing) { List previousLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getCustomerId, customerId) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) .eq(DispatchPalletLock::getStatus, "ACTIVE")); - previousItems.stream().filter(item -> item.getPlanPalletId() != null - && !selectedPalletIds.contains(item.getPlanPalletId())) + previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item))) .forEach(item -> { - previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())) + previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())) .forEach(lock -> releaseLock(lock, now)); items.deleteById(item.getId()); }); } - Set existingPalletIds = previousItems.stream() - .filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId())) - .map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + Set existingPalletIds = previousItems.stream() + .map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet()); for (DispatchPalletCandidate candidate : selected) { DispatchPalletLock lock = activeLock(customerId, candidate); @@ -373,6 +393,7 @@ public class DispatchService { lock.setActiveKey(candidate.sourcePalletKey()); lock.setPlanId(candidate.planId()); lock.setPlanPalletId(candidate.planPalletId()); + lock.setReturnReceiptId(candidate.returnReceiptId()); lock.setWarehousePalletId(candidate.warehousePalletId()); lock.setDispatchOrderId(dispatch.getId()); lock.setOperatorId(trimToNull(operatorId)); @@ -387,13 +408,14 @@ public class DispatchService { "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); } } - if (existingPalletIds.contains(candidate.planPalletId())) continue; + if (existingPalletIds.contains(selectionKey(candidate))) continue; DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setPlanId(candidate.planId()); item.setPlanPalletId(candidate.planPalletId()); + item.setReturnReceiptId(candidate.returnReceiptId()); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); @@ -564,7 +586,7 @@ public class DispatchService { .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) .eq(DispatchPalletLock::getStatus, "ACTIVE")); - List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) + List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) .toList(); return removeLockedItem(dispatch, item, matchingLocks); @@ -705,6 +727,15 @@ public class DispatchService { } return found.get(0); } + private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); } + private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); } + private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) { + if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源"); + if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId()); + var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId())); + if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排"); + return found.get(0); + } private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) { DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 3bac8a9b2..da23f8550 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -41,6 +41,7 @@ public class InventoryCommandService { @Resource private InventoryLedgerMapper ledgers; @Resource private InventoryOperationMapper operations; @Resource private InventoryCommandMapper commands; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Resource private WarehouseResourceGateway warehouseGateway; @Resource private ObjectMapper objectMapper; @@ -335,6 +336,17 @@ public class InventoryCommandService { InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); if (existing != null) return replayReturned(existing, requestHash); + Map returnOwners = new LinkedHashMap<>(); + for (var source : sources.values()) { + var owner = returnReceipts.owner(customerId, exceptionNo, outboundNo, source.outboundPalletItemId(), source.inventoryPositionId()); + if (owner == null || owner.orderId() == null || !Objects.equals(owner.inboundOrderId(), request.inboundOrderId())) + throw conflict("Returned stock receipt is missing the original order attribution"); + returnOwners.put(source.outboundPalletItemId(), owner); + } + for (Long orderId : returnOwners.values().stream().map(com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner::orderId).distinct().sorted().toList()) { + if (returnReceipts.lockOrder(customerId, orderId) == null) throw conflict("Returned stock source order is missing or held"); + } + InventoryOperation operation = new InventoryOperation(); operation.setId(IdWorker.getId()); operation.setCustomerId(customerId); @@ -430,6 +442,16 @@ public class InventoryCommandService { normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(), "Exception return received with evidence " + request.evidenceFileIds().size()); ledgerIds.add(ledger.getId()); + var owner = returnOwners.get(source.outboundPalletItemId()); + var stockReceipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + stockReceipt.setId(IdWorker.getId()); stockReceipt.setCustomerId(customerId); + stockReceipt.setReceiptNo("RSR" + stockReceipt.getId()); stockReceipt.setInventoryLedgerId(ledger.getId()); + stockReceipt.setExceptionOrderId(owner.exceptionOrderId()); stockReceipt.setOrderId(owner.orderId()); + stockReceipt.setInboundOrderId(owner.inboundOrderId()); stockReceipt.setSourceOutboundNo(outboundNo); + stockReceipt.setSourceOutboundPalletItemId(source.outboundPalletItemId()); stockReceipt.setSourceInventoryPositionId(source.inventoryPositionId()); + stockReceipt.setInventoryBalanceId(balance.getId()); stockReceipt.setInventoryPositionId(target.getId()); + stockReceipt.setReceivedPackages(receipt.receivedPackages()); stockReceipt.setReceivedAt(ledger.getOccurredAt()); + stockReceipt.setCreatedAt(ledger.getOccurredAt()); returnReceipts.insert(stockReceipt); restoredPackages += receipt.receivedPackages(); } long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java new file mode 100644 index 000000000..12f0f87f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** An immutable receipt from a real return ledger; it is not another customer order or initial inbound receipt. */ +@Data +@TableName("ll_return_stock_receipt") +public class ReturnStockReceipt { + @TableId private Long id; + private String customerId; + private String receiptNo; + private Long inventoryLedgerId; + private Long exceptionOrderId; + private Long orderId; + private Long inboundOrderId; + private String sourceOutboundNo; + private Long sourceOutboundPalletItemId; + private Long sourceInventoryPositionId; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private Long receivedPackages; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java new file mode 100644 index 000000000..7183b318f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface ReturnStockReceiptMapper extends BaseMapper { + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id); + record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { } + @Select("SELECT e.id AS exception_order_id,e.last_mile_order_id AS order_id,e.inbound_order_id " + + "FROM ll_exception_order e JOIN ll_order o ON o.customer_id=e.customer_id AND o.id=e.last_mile_order_id " + + "JOIN ll_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " + + "WHERE e.customer_id=#{customerId} AND e.exception_no=#{exceptionNo} AND e.outbound_no=#{outboundNo} " + + "AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE' AND e.status='PROCESSING' " + + "AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}") + ReturnOwner owner(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo, + @Param("outboundNo") String outboundNo, @Param("itemId") Long itemId, @Param("positionId") Long positionId); + + @Select("SELECT r.* FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=#{positionId} " + + "AND p.customer_id=r.customer_id AND p.balance_id=r.inventory_balance_id " + + "WHERE r.customer_id=#{customerId} AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) " + + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) " + + "ORDER BY r.received_at,r.id") + List available(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + ReturnStockReceipt lock(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND return_receipt_id=#{id}") + long picked(@Param("customerId") String customerId, @Param("id") Long id); +} 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 26dd83ad7..2119553a5 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 @@ -220,18 +220,24 @@ public interface LastlegOrderMapper extends BaseMapper { @Param("expectedVersion") int expectedVersion, @Param("updatedAt") java.time.LocalDateTime updatedAt); + String RETURNED_PACKAGES = "(SELECT COALESCE(SUM(r.received_packages),0) FROM ll_return_stock_receipt r " + + "WHERE r.customer_id=ll_order.customer_id AND r.order_id=ll_order.id)"; + + @Select("SELECT COALESCE(SUM(received_packages),0) FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND order_id=#{id}") + long returnedPackages(@Param("customerId") String customerId, @Param("id") Long id); + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " + - "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + "AND #{packages}>0 AND package_count+" + RETURNED_PACKAGES + "-shipped_package_count-picked_package_count >= #{packages}") int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages); - @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " + + @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count+" + RETURNED_PACKAGES + " " + "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + "picked_package_count=picked_package_count-#{packages}," + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " + - "AND shipped_package_count+#{packages} <= package_count") + "AND #{packages}>0 AND shipped_package_count+#{packages} <= package_count+" + RETURNED_PACKAGES) int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages, @Param("outboundNo") String outboundNo); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 441e97d42..75f0a3803 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1763,6 +1763,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( source_business_type VARCHAR(32) NULL COMMENT '来源业务类型', source_business_no VARCHAR(128) NULL COMMENT '来源业务单号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), KEY idx_ll_dispatch_item_order (customer_id,order_id), @@ -1794,6 +1796,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab released_at DATETIME(3) NULL COMMENT '释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), @@ -1862,10 +1866,39 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (customer_id,idempotency_key) ) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; +CREATE TABLE ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; + CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), + + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) ) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; 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 7a0c3751c..63b1de0d5 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2401,21 +2401,28 @@ components: packageCount: { type: integer } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } + sourceType: { type: string, enum: [INBOUND_PLAN, RETURN_RECEIPT], description: 正式实物来源类型 } + returnReceiptId: { type: string, nullable: true, description: 退回实收来源ID,不作为计划托盘ID } + returnReceiptNo: { type: string, nullable: true, description: 退回实收业务编号 } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } SmartDispatchPreArrange: type: object - required: [planPalletIds] + description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。 properties: - planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } + planPalletIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string } } + returnReceiptIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string }, description: 原订单实际退回入账来源ID } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } draftToken: { type: string, nullable: true, maxLength: 64, description: 拖入托盘时使用的智能排车草稿标识 } SmartDispatchDraftPallet: type: object - required: [planPalletId, draftToken] + required: [draftToken] + description: planPalletId和returnReceiptId恰有一个;退回来源必须声明sourceType=RETURN_RECEIPT。并发占用始终按客户及实物托盘锁定。 properties: planPalletId: { type: string } + returnReceiptId: { type: string } + sourceType: { type: string, enum: [INBOUND_PLAN, RETURN_RECEIPT] } draftToken: { type: string, minLength: 1, maxLength: 64 } draftContainerId: { type: string, nullable: true, description: 托盘所在草稿车厢ID } SmartDispatchDraftRelease: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java new file mode 100644 index 000000000..8001a026f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +/** Executes the production annotation SQL against real H2 tables, not mocked mapper results. */ +class DeliverySourceEvidenceMapperTest { + @Test void historySurvivesCurrentDeliveryRebindingAndKeepsAllCoShippedSources() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,delivery_order_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); + db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S-A',4,'NEW-DELIVERY'),(102,'C','FIRSTLEG','S-B',6,'OTHER-DELIVERY')"); + db.update("INSERT INTO ll_dispatch_order VALUES(10,'C','2026-10-03 01:00:00')"); + db.update("INSERT INTO ll_delivery_order VALUES(20,'C',10,'CODE:A',10,8,'2026-10-03 03:00:00','OUT-1')"); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(1,'C',10)"); + db.update("INSERT INTO ll_dispatch_item VALUES(7,'C',10,'FBA',NULL,NULL,'a',NULL,NULL)"); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(2,'C',1,7)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(3,'C',2,101,4,30,NULL),(4,'C',2,102,6,31,NULL)"); + db.update("INSERT INTO ll_exception_order VALUES(30,'C',20,'EX-1',101)"); + db.update("INSERT INTO ll_inventory_ledger VALUES(40,'C','EX-1','OUT-1','RETURN_STOCK_IN',2,'2026-10-03 02:00:00','RETURN_SOURCE:7:8'),(41,'C','EX-1','OUT-1','RETURN_STOCK_IN',1,'2026-10-03 04:00:00','RETURN_SOURCE:7:8')"); + var args = Map.of("customerId","C","sourceInboundId","S-A","orderId",101L,"dispatchId",10L); + assertThat(named.queryForList(sql("source"),args)).singleElement().satisfies(r -> assertThat(((Number)r.get("packages")).longValue()).isEqualTo(4)); + assertThat(named.queryForList(sql("dispatchIds"),args)).hasSize(1); + assertThat(named.queryForList(sql("deliveries"),args)).singleElement().satisfies(r -> assertThat(r.get("departed_at")).isNotNull()); + var picks = named.queryForList(sql("picks"),args); + assertThat(picks).hasSize(2); assertThat(picks.stream().mapToLong(r -> ((Number)r.get("packages")).longValue()).sum()).isEqualTo(10); + assertThat(picks).allSatisfy(r -> assertThat(r.get("group_key")).isEqualTo("CODE:A")); + assertThat(named.queryForList(sql("returns"),args)).hasSize(2); + db.update("INSERT INTO ll_return_stock_receipt VALUES(90,'C',101,40,2,30,'OUT-1',7,8)"); + db.update("UPDATE ll_dispatch_pda_pick_order SET receipt_id=NULL,return_receipt_id=90,packages=2 WHERE id=3"); + var linked=named.queryForList(sql("picks"),args).get(0); + assertThat(((Number)linked.get("return_ledger_id")).longValue()).isEqualTo(40); + assertThat(((Number)linked.get("return_packages")).longValue()).isEqualTo(2); + db.update("UPDATE ll_return_stock_receipt SET order_id=102 WHERE id=90"); + assertThat(named.queryForList(sql("picks"),args).get(0).get("return_ledger_id")).isNull(); + for (var method : List.of("source","dispatchIds","deliveries","picks","returns")) { + var other = new HashMap<>(args); other.put("customerId","OTHER"); + assertThat(named.queryForList(sql(method),other)).isEmpty(); + } + } + private String sql(String name) { + var method = Arrays.stream(DeliverySourceEvidenceMapper.class.getMethods()).filter(m -> m.getName().equals(name)).findFirst().orElseThrow(); + return String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}", ":$1"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java new file mode 100644 index 000000000..f237273fc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java @@ -0,0 +1,228 @@ +package com.baiyue.lastleg.delivery; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.service.*; +import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.*; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Production document/query/policy/outbox flow, with actual SQL state and transactions. + * Document BaseMapper plumbing is implemented with JDBC answers; source annotation SQL is executed verbatim. + */ +class DeliverySourcePublicationPersistenceTest { + static class Fixture { + final DriverManagerDataSource ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + final JdbcTemplate db=new JdbcTemplate(ds); + final NamedParameterJdbcTemplate named=new NamedParameterJdbcTemplate(ds); + final DataSourceTransactionManager manager=new DataSourceTransactionManager(ds); + DeliveryDocumentService service=new DeliveryDocumentService(); + volatile boolean failOutbox, failRevision; + DeliverySourceEvidenceMapper mapper; + volatile Runnable beforeFirstSourceRead = () -> {}; + final Map> sourceLocks=new ConcurrentHashMap<>(); + Fixture() { + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); + db.execute("ALTER TABLE ll_order ADD sub_order_status VARCHAR(128)"); + db.execute("ALTER TABLE ll_delivery_order ADD version BIGINT DEFAULT 0"); + db.execute("ALTER TABLE ll_delivery_order ADD status VARCHAR(32) DEFAULT 'DELIVERING'"); + db.execute("ALTER TABLE ll_delivery_order ADD documents_json CLOB"); + db.execute("CREATE TABLE proof_revision(delivery_id BIGINT,command_key VARCHAR(128),request_hash VARCHAR(128),snapshot CLOB, UNIQUE(delivery_id,command_key))"); + db.execute("CREATE TABLE proof_outbox(event_key VARCHAR(255) PRIMARY KEY,aggregate_id VARCHAR(128),customer_id VARCHAR(128),payload CLOB)"); + db.execute("CREATE TABLE proof_operation(delivery_id BIGINT,node VARCHAR(80),result CLOB)"); + mapper = (DeliverySourceEvidenceMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{DeliverySourceEvidenceMapper.class},(proxy,method,args) -> { + if(method.getName().equals("source")) beforeFirstSourceRead.run(); + if(method.getName().equals("lockSource")) sourceLocks.computeIfAbsent(Thread.currentThread().getName(),k->new CopyOnWriteArrayList<>()).add((Long)args[1]); + String sql=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1"); + String name=method.getName(); String parameter=name.equals("source")?"sourceInboundId":name.equals("dispatchIds")||name.equals("lockSource")?"orderId":"dispatchId"; + var rows=named.query(sql,Map.of("customerId",args[0],parameter,args[1]),(r,n)-> switch(name) { + case "source","lockSource" -> new Source(r.getString("source_inbound_id"),r.getLong("order_id"),r.getLong("packages")); + case "dispatchIds" -> r.getLong(1); + case "deliveries" -> new Delivery(r.getLong("delivery_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("packages"),r.getObject("signed_packages",Long.class),r.getObject("signed_at",LocalDateTime.class),r.getObject("departed_at",LocalDateTime.class)); + case "picks" -> new Pick(r.getLong("attribution_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("order_id"),r.getLong("packages"),r.getObject("receipt_id",Long.class),r.getObject("return_receipt_id",Long.class),r.getObject("return_ledger_id",Long.class),r.getObject("return_packages",Long.class)); + case "returns" -> new Returned(r.getLong("ledger_id"),r.getLong("delivery_id"),r.getLong("order_id"),r.getLong("packages"),r.getObject("received_at",LocalDateTime.class)); + default -> throw new IllegalStateException(name); + }); + return name.equals("source")||name.equals("lockSource") ? rows.isEmpty()?null:rows.get(0) : rows; + }); + var orders=mock(DeliveryOrderMapper.class); + when(orders.lockByNo(anyString(),anyString())).thenAnswer(a -> delivery(Long.parseLong(a.getArgument(1)),true)); + when(orders.updateById(any(DeliveryOrder.class))).thenAnswer(a -> { + DeliveryOrder d=a.getArgument(0); + return db.update("UPDATE ll_delivery_order SET signed_packages=?,signed_at=?,version=?,status=?,documents_json=? WHERE id=?",d.getSignedPackages(),d.getSignedAt(),d.getVersion(),d.getStatus(),d.getDocumentsJson(),d.getId()); + }); + var tails=mock(LastlegOrderMapper.class); + when(tails.selectList(any())).thenReturn(List.of()); // Current delivery relation deliberately unavailable. + when(tails.selectBatchIds(anyCollection())).thenAnswer(a -> { + Collection ids=a.getArgument(0); var result=new ArrayList(); + for(Object id:ids) result.add(tail(((Number)id).longValue())); return result; + }); + when(tails.selectById(anyLong())).thenAnswer(a -> tail(a.getArgument(0))); + when(tails.updateById(any(LastlegOrder.class))).thenAnswer(a -> { + LastlegOrder t=a.getArgument(0); return db.update("UPDATE ll_order SET sub_order_status=? WHERE id=?",t.getSubOrderStatus(),t.getId()); + }); + var revisions=mock(DeliveryDocumentRevisionMapper.class); + when(revisions.findCommand(anyString(),anyLong(),anyString())).thenAnswer(a -> { + var rows=db.query("SELECT * FROM proof_revision WHERE delivery_id=? AND command_key=?",(r,n)-> { + var v=new DeliveryDocumentRevision();v.setRequestHash(r.getString("request_hash"));v.setSnapshotJson(r.getString("snapshot"));return v; + },a.getArgument(1),a.getArgument(2)); return rows.isEmpty()?null:rows.get(0); + }); + when(revisions.insert(any(DeliveryDocumentRevision.class))).thenAnswer(a->{ + DeliveryDocumentRevision v=a.getArgument(0);if(failRevision)throw new IllegalStateException("Injected revision failure");return db.update("INSERT INTO proof_revision VALUES(?,?,?,?)",v.getDeliveryOrderId(),v.getIdempotencyKey(),v.getRequestHash(),v.getSnapshotJson()); + }); + var operations=mock(DeliveryOperationMapper.class); + when(operations.returnReceipts(anyString(),anyLong())).thenAnswer(a -> db.query( + "SELECT l.id,l.delta_packages,l.occurred_at FROM ll_inventory_ledger l JOIN ll_exception_order e ON e.exception_no=l.business_order_no WHERE e.delivery_order_id=?", + (r,n)->new DeliveryReturnReceiptFact(r.getLong(1),1L,1L,r.getLong(2),r.getObject(3,LocalDateTime.class)),new Object[]{a.getArgument(1)})); + when(operations.insert(any(DeliveryOperation.class))).thenAnswer(a->{DeliveryOperation o=a.getArgument(0);return db.update("INSERT INTO proof_operation VALUES(?,?,?)",o.getDeliveryOrderId(),o.getOperationNode(),o.getResult());}); + var outbox=mock(LastlegEventOutboxMapper.class); + when(outbox.insert(any(LastlegEventOutbox.class))).thenAnswer(a->{LastlegEventOutbox o=a.getArgument(0);if(failOutbox)throw new IllegalStateException("Injected outbox failure");return db.update("INSERT INTO proof_outbox VALUES(?,?,?,?)",o.getEventKey(),o.getAggregateId(),o.getCustomerId(),o.getPayloadJson());}); + when(outbox.findByEventKey(anyString())).thenAnswer(a->{var rows=db.query("SELECT * FROM proof_outbox WHERE event_key=?",(r,n)->{ + var o=new LastlegEventOutbox();o.setAggregateId(r.getString("aggregate_id"));o.setCustomerId(r.getString("customer_id"));return o;},new Object[]{a.getArgument(0)});return rows.isEmpty()?null:rows.get(0);}); + var publisher=new DeliveryCompletedOutboxService(); var json=new ObjectMapper().findAndRegisterModules(); + ReflectionTestUtils.setField(publisher,"outbox",outbox);ReflectionTestUtils.setField(publisher,"objectMapper",json); + var outbound=mock(OutboundOrderMapper.class);var shipped=new OutboundOrder();shipped.setStatus("OUTBOUNDED"); + when(outbound.findByNo(anyString(),anyString())).thenReturn(shipped); + var files=mock(DeliveryDocumentFileGateway.class); + when(files.metadata(anyString(),anyLong(),any(),anyLong())).thenReturn(new com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File(1L,"receipt.png","image/png",1,LocalDateTime.of(2026,1,1,0,0))); + Map fields=Map.ofEntries(Map.entry("orders",orders),Map.entry("outboundOrders",outbound),Map.entry("revisions",revisions),Map.entry("operations",operations),Map.entry("files",files),Map.entry("objectMapper",json),Map.entry("lastlegOrders",tails),Map.entry("deliveryCompletedOutbox",publisher),Map.entry("customerVisits",mock(CustomerVisitService.class)),Map.entry("sourceEvidence",mapper),Map.entry("sourceCompletion",new SourceDeliveryCompletionQuery(mapper))); + fields.forEach((name,value)->ReflectionTestUtils.setField(service,name,value)); + var proxyFactory=new org.springframework.aop.framework.ProxyFactory(service);proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); + service=(DeliveryDocumentService)proxyFactory.getProxy(); + } + DeliveryOrder delivery(long id,boolean lock) { + return db.queryForObject("SELECT * FROM ll_delivery_order WHERE id=?"+(lock?" FOR UPDATE":""),(r,n)->{ + var d=new DeliveryOrder();d.setId(id);d.setCustomerId("C");d.setDeliveryNo(String.valueOf(id));d.setOutboundNo(r.getString("outbound_no"));d.setDispatchOrderId(r.getLong("dispatch_order_id"));d.setDeliveryGroupKey(r.getString("delivery_group_key"));d.setPackageCount(r.getInt("package_count"));d.setSignedPackages(r.getObject("signed_packages",Integer.class));d.setSignedAt(r.getObject("signed_at",LocalDateTime.class));d.setVersion(r.getLong("version"));d.setStatus(r.getString("status"));d.setDocumentsJson(r.getString("documents_json"));return d; + },id); + } + LastlegOrder tail(long id) { + return db.queryForObject("SELECT * FROM ll_order WHERE id=?",(r,n)->{var o=new LastlegOrder();o.setId(id);o.setCustomerId("C");o.setSourceRegion("FIRSTLEG");o.setSourceInboundId(r.getString("source_inbound_id"));o.setPackageCount(r.getInt("package_count"));o.setSubOrderStatus(r.getString("sub_order_status"));o.setOrderNo("LM-"+id);o.setInboundCode("IN-"+id);o.setSourceOrderId("SOURCE-"+id);o.setSourceOrderNo("FL-"+id);o.setRootOrderId("ROOT-"+id);return o;},id); + } + TransactionTemplate tx() { var tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);return tx; } + Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); } + void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); } + void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); } + void shipment(long id,int packages) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id); + db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id); + db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id); + } + void pick(long id,long dispatch,long order,int packages,Long receipt) { + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,?,?, ?,?)",id,dispatch,order,packages,receipt==null?id:null,receipt); + } + void returned(long id,long delivery,long order,int packages,LocalDateTime at) { + db.update("INSERT INTO ll_exception_order VALUES(?,'C',?,?,?)",id,delivery,"EX-"+id,order); + db.update("INSERT INTO ll_inventory_ledger VALUES(?,'C',?,?,'RETURN_STOCK_IN',?,?,?)",id,"EX-"+id,"OUT-"+delivery,packages,at,"RETURN_SOURCE:7:8"); + db.update("INSERT INTO ll_return_stock_receipt VALUES(?,'C',?,?,?,?,?,7,8)",id,order,id,packages,id,"OUT-"+delivery); + } + List published() { return db.queryForList("SELECT aggregate_id FROM proof_outbox ORDER BY aggregate_id",String.class); } + } + @Test void mixedDeliveryPublishesOnlyFullySignedSourceAndReplayDoesNotRepublish() { + var f=new Fixture();f.source(101,4);f.source(102,6);f.shipment(1,10);f.pick(1,1,101,4,null);f.pick(2,1,102,6,null); + f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"same-key"); + assertThat(f.published()).containsExactly("S-102");assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY"); + assertThat(f.tail(102).getSubOrderStatus()).contains("DELIVERY:DELIVERED"); + f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1); + // Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent. + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1)); + f.pick(3,2,101,2,30L); + // Keep deterministic chronology without sleeping or depending on wall clock granularity. + f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2"); + f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102"); + } + @Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() { + var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first"); + f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1"); + f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0)); + f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2"); + f.pick(2,2,101,4,30L);f.sign(2,"repeat-four"); + assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY"); + } + @Test void concurrentSignaturesPublishOnlyAfterLastSourceLockOwnerSeesBoth() throws Exception { + var f=new Fixture();f.source(101,10);f.source(102,10);f.shipment(1,10);f.shipment(2,10); + f.pick(1,1,102,5,null);f.pick(2,1,101,5,null); // B,A input + f.pick(3,2,101,5,null);f.pick(4,2,102,5,null); // A,B input + var firstLocked=new CountDownLatch(1);var secondUpdated=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2); + f.beforeFirstSourceRead=()->{if(Thread.currentThread().getName().equals("second-signer"))secondUpdated.countDown();}; + try { + var first=pool.submit(()->f.tx().execute(s->{ + Thread.currentThread().setName("first-signer"); + f.delivery(1,true);f.mapper.lockSource("C",101);firstLocked.countDown();await(secondUpdated); + var result=f.service.save("C","1","TEST",f.request("one"));assertThat(f.published()).isEmpty();return result; + })); + var second=pool.submit(()->{Thread.currentThread().setName("second-signer");await(firstLocked);return f.tx().execute(s->{ + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE signed_at IS NOT NULL",Integer.class)).isZero(); + return f.service.save("C","2","TEST",f.request("two")); + });}); + first.get(10,TimeUnit.SECONDS);second.get(10,TimeUnit.SECONDS); + assertThat(f.published()).containsExactly("S-101","S-102");f.sign(2,"two");assertThat(f.published()).containsExactly("S-101","S-102"); + assertThat(f.sourceLocks.get("first-signer")).containsExactly(101L,101L,102L); + assertThat(f.sourceLocks.get("second-signer")).containsExactly(101L,102L); + } finally {pool.shutdownNow();} + } + @Test void springProxyAppliesReadCommittedAndFailureRollsBackSignatureSourceAndOutbox() { + for(boolean revisionFailure:List.of(false,true)) { + var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); + assertThat(org.springframework.aop.support.AopUtils.isAopProxy(f.service)).isTrue(); + f.beforeFirstSourceRead=()->assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + f.failRevision=revisionFailure;f.failOutbox=!revisionFailure; + assertThatThrownBy(()->f.sign(1,"failure")).isInstanceOf(IllegalStateException.class).hasMessageContaining("Injected"); + assertThat(f.delivery(1,false).getSignedAt()).isNull();assertThat(f.delivery(1,false).getSignedPackages()).isNull(); + assertThat(f.delivery(1,false).getVersion()).isZero();assertThat(f.tail(101).getSubOrderStatus()).isNull(); + assertThat(f.published()).isEmpty(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_operation",Integer.class)).isZero(); + f.failRevision=false;f.failOutbox=false;f.sign(1,"failure");assertThat(f.published()).containsExactly("S-101"); + } + } + @Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() { + var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); + f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty(); + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L); + f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty(); + f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L); + f.sign(3,"third");assertThat(f.published()).containsExactly("S-101"); + } + @Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() { + var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); + f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(2,'C',1,'CODE:B',6,'OUT-1')"); + f.sign(1,"source-a");assertThat(f.published()).containsExactly("S-101"); + assertThat(f.delivery(2,false).getSignedAt()).isNull(); + } + private static void await(CountDownLatch latch) { + try {if(!latch.await(6,TimeUnit.SECONDS))throw new IllegalStateException("Timed out");} + catch(InterruptedException e){Thread.currentThread().interrupt();throw new IllegalStateException(e);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java new file mode 100644 index 000000000..37ff4394f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import com.baiyue.lastleg.delivery.service.SourceDeliveryCompletionQuery; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** Two real database transactions execute the production mapper SQL, with bounded waits. */ +class SourceDeliveryCompletionConcurrencyTest { + @Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa",""); + var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); + db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)"); + for (int i=1;i<=2;i++) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i); + db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i); + db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i); + } + var mapper = (DeliverySourceEvidenceMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{DeliverySourceEvidenceMapper.class},(proxy,method,args) -> { + String sql=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1"); + String name=method.getName(); String parameter=name.equals("source")?"sourceInboundId":name.equals("dispatchIds")||name.equals("lockSource")?"orderId":"dispatchId"; + var rows=named.query(sql,Map.of("customerId",args[0],parameter,args[1]),(r,n)-> switch(name) { + case "source","lockSource" -> new Source(r.getString("source_inbound_id"),r.getLong("order_id"),r.getLong("packages")); + case "dispatchIds" -> r.getLong(1); + case "deliveries" -> new Delivery(r.getLong("delivery_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("packages"),r.getObject("signed_packages",Long.class),r.getObject("signed_at",LocalDateTime.class),r.getObject("departed_at",LocalDateTime.class)); + case "picks" -> new Pick(r.getLong("attribution_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("order_id"),r.getLong("packages"),r.getObject("receipt_id",Long.class),r.getObject("return_receipt_id",Long.class),r.getObject("return_ledger_id",Long.class),r.getObject("return_packages",Long.class)); + case "returns" -> new Returned(r.getLong("ledger_id"),r.getLong("delivery_id"),r.getLong("order_id"),r.getLong("packages"),r.getObject("received_at",LocalDateTime.class)); + default -> throw new IllegalStateException(name); + }); + return name.equals("source")||name.equals("lockSource") ? rows.isEmpty()?null:rows.get(0) : rows; + }); + var query=new SourceDeliveryCompletionQuery(mapper); + var manager=new DataSourceTransactionManager(ds); + var sourceLocked=new CountDownLatch(1); var secondDeliveryLocked=new CountDownLatch(1); + var pool=Executors.newFixedThreadPool(2); + try { + var first=pool.submit(()->transaction(manager).execute(status->{ + db.update("UPDATE ll_delivery_order SET signed_packages=5,signed_at='2026-10-03 03:00:00' WHERE id=1"); + mapper.lockSource("C",101); sourceLocked.countDown(); await(secondDeliveryLocked); + return query.prove("C","S"); + })); + var second=pool.submit(()->{ await(sourceLocked); return transaction(manager).execute(status->{ + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE signed_at IS NOT NULL",Integer.class)).isZero(); + db.update("UPDATE ll_delivery_order SET signed_packages=5,signed_at='2026-10-03 03:01:00' WHERE id=2"); + secondDeliveryLocked.countDown(); return query.prove("C","S"); + }); }); + assertThat(first.get(8,TimeUnit.SECONDS).fullySigned()).isFalse(); + assertThat(second.get(8,TimeUnit.SECONDS).fullySigned()).isTrue(); + } finally { pool.shutdownNow(); } + } + private static TransactionTemplate transaction(DataSourceTransactionManager manager) { + var tx=new TransactionTemplate(manager); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); return tx; + } + private static void await(CountDownLatch latch) { + try { if(!latch.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("Concurrency checkpoint timed out"); } + catch(InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java new file mode 100644 index 000000000..7dc39fbef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +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.*; + +class SourceDeliveryCompletionPolicyTest { + private final LocalDateTime departure = LocalDateTime.of(2026, 10, 3, 1, 0); + private final LocalDateTime signature = departure.plusHours(2); + private final List picks = List.of(new Pick(1,10,"CODE:A",101,4,1L,null,null,null), new Pick(2,10,"CODE:A",102,6,2L,null,null,null)); + private Delivery delivery(long signed) { return new Delivery(20,10,"CODE:A",10,signed,signature,departure); } + private Result evaluate(long order, long original, List deliveries, List facts, List returns) { + return SourceDeliveryCompletionPolicy.evaluate(new Source("SOURCE-" + order, order, original), deliveries, facts, returns, true); + } + @Test void onlyReturnedSourceWaitsWhileOtherSourceIsProvenFullySigned() { + var returns = List.of(new Returned(30,20,101,2,departure.plusHours(1))); + assertThat(evaluate(101,4,List.of(delivery(8)),picks,returns)).satisfies(r -> { + assertThat(r.provenSignedPackages()).isEqualTo(2); assertThat(r.fullySigned()).isFalse(); assertThat(r.unresolvedReasons()).isEmpty(); }); + assertThat(evaluate(102,6,List.of(delivery(8)),picks,returns).fullySigned()).isTrue(); + } + @Test void postSignatureReturnDoesNotEraseHistoricSignature() { + assertThat(evaluate(101,4,List.of(delivery(10)),picks,List.of(new Returned(30,20,101,2,signature.plusHours(1)))).fullySigned()).isTrue(); + } + @Test void laterRedeliveryOfPreviouslyUnsignedBoxesCompletesSource() { + var deliveries = List.of(delivery(8), new Delivery(21,11,"CODE:A",2,2L,signature.plusDays(1),signature.plusHours(3))); + var allPicks = List.of(picks.get(0),picks.get(1),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L)); + assertThat(evaluate(101,4,deliveries,allPicks,List.of(new Returned(30,20,101,2,departure.plusHours(1)))).fullySigned()).isTrue(); + } + @Test void totalSignatureCannotBeProportionallyAssignedWhenReturnSourceIsMissing() { + var r = evaluate(102,6,List.of(delivery(8)),picks,List.of()); + assertThat(r.fullySigned()).isFalse(); assertThat(r.unresolvedReasons()).contains("PRE_SIGNATURE_RETURN_UNRESOLVED:20"); + } + @Test void equalTimestampRequiresReconciliation() { + assertThat(evaluate(102,6,List.of(delivery(8)),picks,List.of(new Returned(30,20,101,2,signature))).unresolvedReasons()).isNotEmpty(); + } + @Test void duplicateLedgerAndIncompleteHistoryNeverProveFullSignature() { + var returned = new Returned(30,20,101,1,departure.plusHours(1)); + assertThat(evaluate(102,6,List.of(delivery(8)),picks,List.of(returned,returned)).fullySigned()).isFalse(); + assertThat(SourceDeliveryCompletionPolicy.evaluate(new Source("S",102,6),List.of(delivery(10)),picks,List.of(),false).fullySigned()).isFalse(); + } + @Test void ambiguousDestinationOrMissingPickCoverageBlocksCompletion() { + assertThat(evaluate(102,6,List.of(delivery(10),new Delivery(21,10,"CODE:A",10,10L,signature,departure)),picks,List.of()).fullySigned()).isFalse(); + assertThat(evaluate(102,6,List.of(delivery(10)),List.of(picks.get(1)),List.of()).fullySigned()).isFalse(); + } + @Test void postSignatureRedeliveryCannotDoubleCountOriginalBoxes() { + assertThat(evaluate(101,4,List.of(delivery(10),new Delivery(21,11,"CODE:A",2,2L,signature.plusDays(1),signature.plusHours(3))), + List.of(picks.get(0),picks.get(1),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L)),List.of()).unresolvedReasons()).contains("RETURN_PICK_ORIGIN_UNRESOLVED:3"); + } + @Test void postSignatureReturnRedeliveryCannotFillTheNeverShippedOriginalRemainder() { + var deliveries=List.of(new Delivery(20,10,"CODE:A",6,6L,signature,departure), + new Delivery(21,11,"CODE:A",4,4L,signature.plusDays(1),signature.plusHours(3))); + var facts=List.of(new Pick(1,10,"CODE:A",101,6,1L,null,null,null), + new Pick(2,11,"CODE:A",101,4,null,300L,30L,4L)); + var result=evaluate(101,10,deliveries,facts,List.of(new Returned(30,20,101,4,signature.plusHours(1)))); + assertThat(result.provenSignedPackages()).isEqualTo(6); + assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).isEmpty(); + } + @Test void missingReceiptOriginCannotSilentlyBecomeFreshCargo() { + var facts=List.of(new Pick(1,10,"CODE:A",101,4,null,null,null,null),picks.get(1)); + assertThat(evaluate(101,4,List.of(delivery(10)),facts,List.of()).unresolvedReasons()).contains("PICK_RECEIPT_ORIGIN_UNRESOLVED"); + } + @Test void unrelatedDestinationMissingEvidenceDoesNotBlockProvenSource() { + var result=evaluate(101,4,List.of(new Delivery(20,10,"CODE:A",4,4L,signature,departure), + new Delivery(21,10,"CODE:B",6,null,null,departure)),List.of(picks.get(0)),List.of()); + assertThat(result.fullySigned()).isTrue();assertThat(result.unresolvedReasons()).isEmpty(); + } + @Test void completeTwoHopPreSignatureReturnsContributeOnlyNewlySignedBoxes() { + var ds=List.of(new Delivery(20,10,"CODE:A",4,2L,departure.plusHours(2),departure), + new Delivery(21,11,"CODE:A",2,1L,departure.plusHours(5),departure.plusHours(3)), + new Delivery(22,12,"CODE:A",1,1L,departure.plusHours(8),departure.plusHours(6))); + var ps=List.of(picks.get(0),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L), + new Pick(4,12,"CODE:A",101,1,null,400L,40L,1L)); + var rs=List.of(new Returned(30,20,101,2,departure.plusHours(1)),new Returned(40,21,101,1,departure.plusHours(4))); + var result=evaluate(101,4,ds,ps,rs); + assertThat(result.fullySigned()).isTrue();assertThat(result.provenSignedPackages()).isEqualTo(4); + } + @Test void repeatedReturnOfAlreadySignedCohortRemainsNonContributing() { + var ds=List.of(new Delivery(20,10,"CODE:A",6,6L,departure.plusHours(2),departure), + new Delivery(21,11,"CODE:A",4,2L,departure.plusHours(6),departure.plusHours(4)), + new Delivery(22,12,"CODE:A",2,2L,departure.plusHours(9),departure.plusHours(7))); + var ps=List.of(new Pick(1,10,"CODE:A",101,6,1L,null,null,null),new Pick(2,11,"CODE:A",101,4,null,300L,30L,4L), + new Pick(3,12,"CODE:A",101,2,null,400L,40L,2L)); + var rs=List.of(new Returned(30,20,101,4,departure.plusHours(3)),new Returned(40,21,101,2,departure.plusHours(5))); + var result=evaluate(101,10,ds,ps,rs); + assertThat(result.fullySigned()).isFalse();assertThat(result.provenSignedPackages()).isEqualTo(6); + assertThat(result.unresolvedReasons()).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java new file mode 100644 index 000000000..53559c431 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import com.baiyue.lastleg.delivery.service.SourceDeliveryCompletionQuery; +import org.apache.ibatis.exceptions.TooManyResultsException; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SourceDeliveryCompletionQueryTest { + private DeliverySourceEvidenceMapper mapper; + private SourceDeliveryCompletionQuery query; + private TransactionTemplate tx; + private final LocalDateTime departure = LocalDateTime.of(2026,10,3,1,0); + @BeforeEach void setup() { + mapper = mock(DeliverySourceEvidenceMapper.class); + var manager = new DataSourceTransactionManager(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID(),"sa","")); + tx = new TransactionTemplate(manager); tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED); + var factory = new ProxyFactory(new SourceDeliveryCompletionQuery(mapper)); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource())); + query = (SourceDeliveryCompletionQuery)factory.getProxy(); + when(mapper.source("C","S")).thenReturn(new Source("S",101,4)); + when(mapper.lockSource("C",101)).thenReturn(new Source("S",101,4)); + when(mapper.dispatchIds("C",101)).thenReturn(List.of(11L,10L)); + when(mapper.deliveries("C",10)).thenReturn(List.of(new Delivery(20,10,"CODE:A",10,8L,departure.plusHours(2),departure))); + when(mapper.picks("C",10)).thenReturn(List.of(new Pick(1,10,"CODE:A",101,4,1L,null,null,null),new Pick(2,10,"CODE:A",102,6,2L,null,null,null))); + when(mapper.returns("C",10)).thenReturn(List.of(new Returned(30,20,101,2,departure.plusHours(1)))); + when(mapper.deliveries("C",11)).thenReturn(List.of(new Delivery(21,11,"CODE:A",2,2L,departure.plusDays(1),departure.plusHours(3)))); + when(mapper.picks("C",11)).thenReturn(List.of(new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L))); + when(mapper.returns("C",11)).thenReturn(List.of()); + } + @Test void requiresExistingWriteTransaction() { + assertThatThrownBy(() -> query.prove("C","S")).isInstanceOf(IllegalTransactionStateException.class); + tx.setReadOnly(true); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(mapper); + } + @Test void collectsEveryDispatchAndAllCoShippedSourcesBeforeProof() { + var result = tx.execute(s -> query.prove("C","S")); + assertThat(result.fullySigned()).isTrue(); assertThat(result.provenSignedPackages()).isEqualTo(4); + var order = inOrder(mapper); + order.verify(mapper).source("C","S"); order.verify(mapper).lockSource("C",101); order.verify(mapper).dispatchIds("C",101); + for (long id : List.of(10L,11L)) { + order.verify(mapper).deliveries("C",id); order.verify(mapper).picks("C",id); order.verify(mapper).returns("C",id); + } + order.verifyNoMoreInteractions(); + } + @Test void missingSourceNeverMarksHistoryComplete() { + when(mapper.source("C","S")).thenReturn(null); + var result = tx.execute(s -> query.prove("C","S")); + assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).contains("HISTORY_NOT_COMPLETE"); + verify(mapper,never()).dispatchIds(anyString(),anyLong()); + } + @Test void laterDispatchFailurePropagatesWithoutReturningEarlierPartialProof() { + when(mapper.returns("C",11)).thenThrow(new org.springframework.dao.DataAccessResourceFailureException("offline")); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(org.springframework.dao.DataAccessResourceFailureException.class); + } + @Test void missingDispatchEvidenceIsUnresolved() { + when(mapper.picks("C",11)).thenReturn(List.of()); + var result = tx.execute(s -> query.prove("C","S")); + assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).contains("HISTORY_NOT_COMPLETE"); + } + @Test void brokenSourceUniquenessNeverSelectsOneOrSumsRows() { + when(mapper.source("C","S")).thenThrow(new TooManyResultsException("duplicate source")); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(TooManyResultsException.class); + verify(mapper,never()).dispatchIds(anyString(),anyLong()); + } + @Test void wrongDispatchReturnIsRejected() { + when(mapper.returns("C",11)).thenReturn(List.of(new Returned(50,20,101,1,departure.plusHours(4)))); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class); + } + @Test void repeatableReadAndUnspecifiedIsolationAreRejectedBeforeReadingEvidence() { + for(int isolation : List.of(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ,org.springframework.transaction.TransactionDefinition.ISOLATION_DEFAULT)) { + tx.setIsolationLevel(isolation); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class).hasMessageContaining("READ_COMMITTED"); + } + verifyNoInteractions(mapper); + } + @Test void multipleSourcesLockNumericOrderIdsBeforeAnyHistoryRead() { + when(mapper.source("C","Z")).thenReturn(new Source("Z",50,2)); + when(mapper.lockSource("C",50)).thenReturn(new Source("Z",50,2)); + when(mapper.dispatchIds("C",50)).thenReturn(List.of()); + tx.execute(s -> query.proveAll("C",List.of("S","Z"))); + var order=inOrder(mapper); + order.verify(mapper).source("C","S"); order.verify(mapper).source("C","Z"); + order.verify(mapper).lockSource("C",50); order.verify(mapper).lockSource("C",101); + order.verify(mapper).dispatchIds("C",50); order.verify(mapper).dispatchIds("C",101); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 8b5778182..7eae1933b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -34,7 +34,7 @@ class DeliveryCompletedOutboxServiceTest { delivery.setPackageCount(10); delivery.setSignedPackages(10); service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), - order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z")); + order(12, "102", "202", "IN-2", "ACY2")), proofs(), Instant.parse("2026-09-15T08:00:00Z")); ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegEventOutbox.class); verify(mapper, times(2)).insert(rows.capture()); @@ -62,7 +62,7 @@ class DeliveryCompletedOutboxServiceTest { relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION"); relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1"); - service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z")); + service.publish(delivery, List.of(relabel), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z")); verifyNoInteractions(mapper); } @@ -79,7 +79,7 @@ class DeliveryCompletedOutboxServiceTest { transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER"); transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1"); - service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z")); + service.publish(delivery, List.of(transfer), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z")); verifyNoInteractions(mapper); } @@ -96,7 +96,7 @@ class DeliveryCompletedOutboxServiceTest { local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER"); local.setCustomerId("88"); local.setSourceInboundId("RETURN-1"); - service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z")); + service.publish(delivery, List.of(local), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z")); verifyNoInteractions(mapper); } @@ -108,16 +108,20 @@ class DeliveryCompletedOutboxServiceTest { var sources = List.of(order(11, "101", "201", "IN-1", "ONT8")); for (Integer invalid : java.util.Arrays.asList(null, 0, -1, 11)) { delivery.setSignedPackages(invalid); - assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION); + assertThat(service.publish(delivery, sources, java.util.Map.of(), Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION); } delivery.setSignedPackages(6); - assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); + assertThat(service.publish(delivery, sources, java.util.Map.of(), Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); verifyNoInteractions(mapper); } + private java.util.Map proofs() { + return java.util.Map.of("101",new com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result("101",5,5,true,List.of()), + "102",new com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result("102",5,5,true,List.of())); + } private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); - value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); + value.setSourceRegion("FIRSTLEG"); value.setPackageCount(5); value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); value.setSourceOrderId(orderId); value.setSourceOrderNo("FL-" + orderId); value.setRootOrderId(orderId); value.setFbaWarehouseCode(fba); return value; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index d0057df50..2fbfbf6b8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -33,6 +33,8 @@ class DeliveryDocumentServiceTest { @Mock LastlegOrderMapper lastlegOrders; @Mock DeliveryCompletedOutboxService deliveryCompletedOutbox; @Mock CustomerVisitService customerVisits; + @Mock SourceDeliveryCompletionQuery sourceCompletion; + @Mock DeliverySourceEvidenceMapper sourceEvidence; DeliveryDocumentService service; DeliveryOrder order; @BeforeEach void setUp() { @@ -46,6 +48,8 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders); ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox); ReflectionTestUtils.setField(service,"customerVisits",customerVisits); + ReflectionTestUtils.setField(service,"sourceCompletion",sourceCompletion); + ReflectionTestUtils.setField(service,"sourceEvidence",sourceEvidence); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); order.setOutboundNo("OUT-1"); order.setPackageCount(10); @@ -142,6 +146,7 @@ class DeliveryDocumentServiceTest { when(orders.lockByNo("100","DL-1")).thenReturn(order); when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); when(lastlegOrders.selectList(any())).thenReturn(List.of(related)); + when(lastlegOrders.selectById(20L)).thenReturn(related); service.save("100","DL-1","9",request(0,"receipt",List.of(new Binding(RECEIPT,List.of(13L))))); @@ -149,7 +154,7 @@ class DeliveryDocumentServiceTest { assertThat(order.getSignedAt()).isNotNull(); assertThat(related.getSubOrderStatus()).isEqualTo("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED"); verify(lastlegOrders).updateById(related); - verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any()); + verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), anyMap(), any()); verify(customerVisits).ensureForCompletedDelivery(eq(order), any()); ArgumentCaptor operation = ArgumentCaptor.forClass(DeliveryOperation.class); verify(operations, times(2)).insert(operation.capture()); @@ -215,6 +220,20 @@ class DeliveryDocumentServiceTest { verify(files,never()).download(anyString(),anyLong(),any(),anyLong()); verifyNoInteractions(revisions,operations); } + @Test void pendingSourceAuditUsesBusinessLabelAndChineseReasonWithoutInternalIdentifiers() { + order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDeliveryGroupKey("CODE:A"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + var outbound=new OutboundOrder();outbound.setStatus("OUTBOUNDED");when(outboundOrders.findByNo("100","OUT-1")).thenReturn(outbound); + var tail=new LastlegOrder();tail.setId(20L);tail.setCustomerId("100");tail.setSourceRegion("FIRSTLEG");tail.setSourceInboundId("S"); + when(sourceEvidence.picks("100",50L)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(123456,50,"CODE:A",20,10,1L,null,null,null))); + when(lastlegOrders.selectBatchIds(anyCollection())).thenReturn(List.of(tail));when(lastlegOrders.selectById(20L)).thenReturn(tail); + when(sourceCompletion.proveAll(eq("100"),anyCollection())).thenReturn(java.util.Map.of("S",new SourceDeliveryCompletionPolicy.Result("S",10,6,false,List.of("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:987654")))); + service.save("100","DL-1","9",request(0,"audit",List.of(new Binding(RECEIPT,List.of(13L))))); + var operationsSaved=ArgumentCaptor.forClass(DeliveryOperation.class);verify(operations,times(3)).insert(operationsSaved.capture()); + var text=operationsSaved.getAllValues().stream().filter(o->"FIRSTLEG_COMPLETION_PENDING".equals(o.getOperationNode())).findFirst().orElseThrow().getResult(); + assertThat(text).contains("—","6/10","来源证据尚未核齐").doesNotContain("987654","123456","UNRESOLVED","null"); + } @Test void remainingSignatureFreezesActualQuantityAndPersistsDomesticWritebackBlocker() { order.setStatus("RETURN_PENDING"); when(orders.lockByNo("100", "DL-1")).thenReturn(order); @@ -222,7 +241,8 @@ class DeliveryDocumentServiceTest { var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED"); when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); when(operations.returnReceipts("100", 10L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 2L, 3L, 4, LocalDateTime.now().minusMinutes(1)))); - when(deliveryCompletedOutbox.publish(eq(order), anyList(), any())).thenReturn(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); + var tail=new LastlegOrder(); tail.setId(20L); tail.setCustomerId("100"); tail.setSourceRegion("FIRSTLEG"); tail.setSourceInboundId("S"); tail.setInboundCode("IN-S"); + when(lastlegOrders.selectList(any())).thenReturn(List.of(tail)); service.save("100", "DL-1", "9", request(0, "residual-signature", List.of(new Binding(RECEIPT, List.of(13L))))); assertThat(order.getPackageCount()).isEqualTo(10); assertThat(order.getSignedPackages()).isEqualTo(6); assertThat(order.getSignedAt()).isNotNull(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 4fb109ff9..141d1c508 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -54,7 +54,7 @@ class SmartDispatchControllerTest { void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING"); - when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); + when(service.resolveCustomerScopeForSources(List.of(10L, 11L), List.of())).thenReturn("100"); controller.preArrange(platform(), request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java index 3a7bc627f..fbcf9a294 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -32,6 +32,7 @@ class DispatchPickOrderAttributionServiceTest { @Mock private DispatchPdaPickOrderMapper attributions; @Mock private LastlegOrderMapper orders; @Mock private LastlegOrderStatusHistoryMapper history; + @Mock private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Test void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException { @@ -108,11 +109,58 @@ class DispatchPickOrderAttributionServiceTest { verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong()); } + @Test void returnedTwoPackagesHaveTheirOwnReceiptAttributionAndRejectExcess() { + var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L); + when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt)); + when(returnReceipts.lock("t1", 201L)).thenReturn(receipt); + when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1); + var service = service(); + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)), + service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L)); + var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class); + verify(attributions).insert(capture.capture()); + assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId()); + when(returnReceipts.picked("t1", 201L)).thenReturn(2L); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L)); + } + + @Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() { + var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L); + when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt)); + when(returnReceipts.lock("t1", 201L)).thenReturn(receipt); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L)); + verify(attributions, never()).insert(any(DispatchPdaPickOrder.class)); + verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong()); + verify(attributions, never()).availableReceipts(anyString(), anyLong()); + } + + @Test void departureLocksSourceOrdersInNumericOrderInsteadOfPickOrder() { + var high = new DispatchPdaPickOrder(); high.setOrderId(10L); high.setPackages(1L); + var low = new DispatchPdaPickOrder(); low.setOrderId(2L); low.setPackages(1L); + when(attributions.listPick("t1", 81L)).thenReturn(List.of(high, low)); + for (long id : List.of(2L, 10L)) { + var order = new LastlegOrder(); order.setId(id); order.setPackageCount(1); + order.setPickedPackageCount(1); order.setShippedPackageCount(0); + when(orders.lockById("t1", id)).thenReturn(order); + when(orders.transferPickedToShipped("t1", id, 1L, "OUT1")).thenReturn(1); + when(orders.markDispatchOutbound("t1", id)).thenReturn(1); + } + service().depart("t1", "OUT1", "DP1", List.of(pick(81L, 91L, 2L, 0L)), LocalDateTime.now()); + var sequence = inOrder(orders); + sequence.verify(orders).lockById("t1", 2L); + sequence.verify(orders).lockById("t1", 10L); + } + private DispatchPickOrderAttributionService service() { DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); ReflectionTestUtils.setField(service, "attributions", attributions); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); return service; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java new file mode 100644 index 000000000..e38d09d47 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class ReturnedDispatchAllowancePersistenceTest { + @Test void realSqlAllowsOnlyTwoReturnedPackagesToDepartAgainWithoutRewritingOriginalTotals() throws Exception { + var db = database(); var named = new NamedParameterJdbcTemplate(db.getDataSource()); + String pick = sql("increasePicked", String.class, Long.class, long.class); + String depart = sql("transferPickedToShipped", String.class, Long.class, long.class, String.class); + var args = Map.of("customerId", "A", "id", 1L, "packages", 2L, "outboundNo", "OUT-NEW"); + assertThat(named.update(pick, args)).isZero(); + db.update("INSERT INTO ll_return_stock_receipt VALUES(10,'A',1,100,2)"); + assertThat(named.update(pick, args)).isEqualTo(1); + assertThat(named.update(pick, args)).isZero(); + assertThat(named.update(depart, args)).isEqualTo(1); + assertThat(named.update(depart, args)).isZero(); + assertThat(db.queryForObject("SELECT package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT shipped_package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(4); + assertThat(db.queryForObject("SELECT picked_package_count FROM ll_order WHERE id=1", Integer.class)).isZero(); + assertThatThrownBy(() -> db.update("INSERT INTO ll_return_stock_receipt VALUES(11,'A',1,100,2)")) + .isInstanceOf(org.springframework.dao.DuplicateKeyException.class); + assertThat(named.update(pick, Map.of("customerId", "OTHER", "id", 1L, "packages", 1L))).isZero(); + } + + @Test void concurrentRedispatchPicksCannotSpendTheSameReturnTwice() throws Exception { + var db = database(); db.update("INSERT INTO ll_return_stock_receipt VALUES(10,'A',1,100,2)"); + var tx = new TransactionTemplate(new DataSourceTransactionManager(db.getDataSource())); + var named = new NamedParameterJdbcTemplate(db.getDataSource()); + String pick = sql("increasePicked", String.class, Long.class, long.class); + var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2); + Callable attempt = () -> { + start.await(); + return tx.execute(status -> { + db.queryForObject("SELECT id FROM ll_order WHERE customer_id='A' AND id=1 FOR UPDATE", Long.class); + db.queryForObject("SELECT id FROM ll_return_stock_receipt WHERE customer_id='A' AND id=10 FOR UPDATE", Long.class); + return named.update(pick, Map.of("customerId", "A", "id", 1L, "packages", 2L)); + }); + }; + try { + var a = pool.submit(attempt); var b = pool.submit(attempt); start.countDown(); + assertThat(a.get(5, TimeUnit.SECONDS) + b.get(5, TimeUnit.SECONDS)).isEqualTo(1); + assertThat(db.queryForObject("SELECT picked_package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(2); + } finally { pool.shutdownNow(); } + } + private JdbcTemplate database() { + var db = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "")); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY, customer_id VARCHAR(20), package_count INT,shipped_package_count INT,picked_package_count INT,detained INT,current_status VARCHAR(40),outbound_order_no VARCHAR(50),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,UNIQUE(customer_id,inventory_ledger_id))"); + db.update("INSERT INTO ll_order(id,customer_id,package_count,shipped_package_count,picked_package_count,detained,current_status) VALUES(1,'A',2,2,0,0,'COMPLETED')"); + return db; + } + private String sql(String name, Class... args) throws Exception { + return String.join(" ", LastlegOrderMapper.class.getMethod(name,args).getAnnotation(Update.class).value()) + .replace("#{customerId}", ":customerId").replace("#{id}", ":id").replace("#{packages}", ":packages").replace("#{outboundNo}", ":outboundNo"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 63e740ef4..be1d5e874 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -37,6 +37,7 @@ import java.util.List; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -55,6 +56,7 @@ class DispatchServiceTest { @Mock DispatchBolMapper bolLines; @Mock DispatchPalletLockMapper locks; @Mock DispatchCandidateMapper candidates; + @Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; @Mock DispatchDraftMapper drafts; @Mock DispatchDraftContainerMapper draftContainers; @Mock DispatchPdaTaskMapper pdaTasks; @@ -72,6 +74,7 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "bolLines", bolLines); ReflectionTestUtils.setField(service, "locks", locks); ReflectionTestUtils.setField(service, "candidates", candidates); + ReflectionTestUtils.setField(service, "returnCandidates", returnCandidates); ReflectionTestUtils.setField(service, "drafts", drafts); ReflectionTestUtils.setField(service, "draftContainers", draftContainers); ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); @@ -497,6 +500,57 @@ class DispatchServiceTest { assertEquals("PLAN:11", plannedOnly.sourcePalletKey()); } + @Test void returnedReceiptPreArrangementUsesItsOwnReferenceAndTheRealTargetPalletLock() throws Exception { + var candidate = candidate(10L, 900L, "ONT8", 2); + candidate.planId(null); candidate.planPalletId(null); candidate.returnReceiptId(701L); candidate.returnReceiptNo("RSR-701"); + when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(candidate)); + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(), null, null, null, null, null, List.of(701L))); + var item = ArgumentCaptor.forClass(DispatchItem.class); verify(items).insert(item.capture()); + assertEquals(null, item.getValue().getPlanPalletId()); assertEquals(701L, item.getValue().getReturnReceiptId()); + assertEquals(2, item.getValue().getPackageCount()); + var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); verify(locks).insert(lock.capture()); + assertEquals("RECEIVED:900", lock.getValue().getActiveKey()); assertEquals(701L, lock.getValue().getReturnReceiptId()); + verify(candidates, never()).lockSelected(any(), any()); + var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(candidate); + assertEquals("RETURN_RECEIPT", json.path("sourceType").asText()); + assertEquals("RSR-701", json.path("returnReceiptNo").asText()); assertTrue(json.path("planPalletId").isNull()); + } + + @Test void plannedAndReturnedSourcesCreateOneDispatchWithBothReferencesAndTheirExactQuantities() { + var planned = candidate(10L, 900L, "ONT8", 3); + var returned = candidate(11L, 901L, "ONT8", 2); + returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); + when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L))); + var dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture()); + assertEquals(5, dispatch.getValue().getPackageCount()); assertEquals(2, dispatch.getValue().getPalletCount()); + var inserted = ArgumentCaptor.forClass(DispatchItem.class); + verify(items, org.mockito.Mockito.times(2)).insert(inserted.capture()); + var normalItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() == null).findFirst().orElseThrow(); + var returnItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() != null).findFirst().orElseThrow(); + assertEquals(10L, normalItem.getPlanPalletId()); assertEquals(3, normalItem.getPackageCount()); + assertEquals(null, returnItem.getPlanPalletId()); assertEquals(701L, returnItem.getReturnReceiptId()); assertEquals(2, returnItem.getPackageCount()); + assertEquals(normalItem.getDispatchOrderId(), returnItem.getDispatchOrderId()); + var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); + verify(locks, org.mockito.Mockito.times(2)).insert(lock.capture()); + assertEquals(java.util.Set.of("RECEIVED:900", "RECEIVED:901"), lock.getAllValues().stream() + .map(DispatchPalletLock::getActiveKey).collect(java.util.stream.Collectors.toSet())); + } + + @Test void mixedSourcesOnTheSamePhysicalPalletAreRejectedBeforeAnyDispatchOrLockWrite() { + var planned = candidate(10L, 900L, "ONT8", 3); + var returned = candidate(11L, 900L, "ONT8", 2); + returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); + when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); + var failure = assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L)))); + assertEquals(409, failure.getStatusCode().value()); assertTrue(failure.getReason().contains("同一实物托盘")); + verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class)); + verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); + } + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { return candidate(planPalletId, warehousePalletId, destination, 12); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java new file mode 100644 index 000000000..fbfd62f09 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class ReturnDispatchCandidatePersistenceTest { + @Test void actualReturnOnANewPalletCanBeSelectedWithoutAnInboundPlanPallet() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(data); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,receipt_no VARCHAR(50),customer_id VARCHAR(20),order_id BIGINT,inventory_position_id BIGINT,inventory_balance_id BIGINT,exception_order_id BIGINT,received_packages BIGINT,received_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),customer_name VARCHAR(50),order_type VARCHAR(50),order_no VARCHAR(50),detained INT)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),pallet_id BIGINT,pallet_no VARCHAR(50),overseas_warehouse_id VARCHAR(50),available_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),overseas_warehouse_name VARCHAR(50),overseas_warehouse_code VARCHAR(50))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),destination_type VARCHAR(20),destination_warehouse_code VARCHAR(50),destination_warehouse_name VARCHAR(50),delivery_address VARCHAR(100),postal_code VARCHAR(50),contact_name VARCHAR(50),contact_phone VARCHAR(50))"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),return_receipt_id BIGINT,packages BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pallet_lock(customer_id VARCHAR(20),source_pallet_key VARCHAR(50),status VARCHAR(20),expires_at TIMESTAMP,draft_token VARCHAR(50))"); + db.update("INSERT INTO ll_order VALUES(1,'A','客户A','FIRST_LEG','LM-1',0)"); + db.update("INSERT INTO ll_inventory_position VALUES(2,'A',900,'RETURN-PALLET','92',2)"); + db.update("INSERT INTO ll_inventory_balance VALUES(3,'A','IN-1','海外仓','WH')"); + db.update("INSERT INTO ll_exception_order VALUES(4,'A',5)"); + db.update("INSERT INTO ll_delivery_order VALUES(5,'A','FBA','ONT8','ONT8',NULL,NULL,NULL,NULL)"); + db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'RSR-6','A',1,2,3,4,2,CURRENT_TIMESTAMP)"); + var config = new MybatisConfiguration(); config.addMapper(ReturnDispatchCandidateMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnDispatchCandidateMapper.class); + var rows = mapper.available("A", "RSR-6", "ONT8", null); + assertThat(rows).hasSize(1); var row = rows.get(0); + assertThat(row.packageCount()).isEqualTo(2); assertThat(row.planPalletId()).isNull(); + assertThat(row.sourceType()).isEqualTo("RETURN_RECEIPT"); assertThat(row.returnReceiptNo()).isEqualTo("RSR-6"); + assertThat(row.warehousePalletId()).isEqualTo(900); assertThat(row.grossWeightKg()).isNull(); + assertThat(mapper.lockSelected("A", List.of(6L))).hasSize(1); + assertThat(mapper.lockSelected("B", List.of(6L))).isEmpty(); + db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RECEIVED:900','ACTIVE',NULL,'another-draft')"); + assertThat(mapper.available("A", null, null, "this-draft")).isEmpty(); + db.update("DELETE FROM ll_dispatch_pallet_lock"); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)"); + // Even if the position also has unrelated available stock, this receipt contributes only its unspent one box. + db.update("UPDATE ll_inventory_position SET available_packages=8 WHERE id=2"); + var partial = mapper.available("A", null, null, null); + assertThat(partial).hasSize(1); assertThat(partial.get(0).packageCount()).isEqualTo(1); + assertThat(mapper.lockSelected("A", List.of(6L)).get(0).packageCount()).isEqualTo(1); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)"); + assertThat(mapper.available("A", null, null, null)).isEmpty(); + assertThat(mapper.lockSelected("A", List.of(6L))).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 7a7110092..89733caf6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -51,6 +51,7 @@ class InventoryCommandServiceTest { @Mock InventoryLedgerMapper ledgers; @Mock InventoryOperationMapper operations; @Mock InventoryCommandMapper commands; + @Mock com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Mock WarehouseResourceGateway warehouseGateway; private InventoryCommandService service; @@ -63,6 +64,10 @@ class InventoryCommandServiceTest { ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); + lenient().when(returnReceipts.owner(any(), any(), any(), any(), any())).thenReturn( + new com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner(1L, 51L, 101L)); + lenient().when(returnReceipts.lockOrder(any(), any())).thenAnswer(call -> call.getArgument(1)); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); @@ -385,6 +390,12 @@ class InventoryCommandServiceTest { assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType()); assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId()); assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo()); + ArgumentCaptor returned = ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.entity.ReturnStockReceipt.class); + verify(returnReceipts).insert(returned.capture()); + assertEquals(ledger.getValue().getId(), returned.getValue().getInventoryLedgerId()); + assertEquals(51L, returned.getValue().getOrderId()); + assertEquals(3L, returned.getValue().getReceivedPackages()); + assertEquals(target.getValue().getId(), returned.getValue().getInventoryPositionId()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java index 4da37378b..27e4c0ba8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -58,7 +58,8 @@ class InventoryHoldConcurrencyPersistenceTest { jdbc = new JdbcTemplate(ds); for (Class entity : List.of(InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class, LastlegOrder.class, LastlegOrderCommandRecord.class, - LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, DispatchPdaPickOrder.class)) { + LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, + ReturnStockReceipt.class, DispatchPdaPickOrder.class)) { String columns = Arrays.stream(entity.getDeclaredFields()) .filter(f -> !Modifier.isStatic(f.getModifiers())) .filter(f -> f.getAnnotation(TableField.class) == null || f.getAnnotation(TableField.class).exist()) @@ -74,7 +75,8 @@ class InventoryHoldConcurrencyPersistenceTest { for (Class mapper : List.of(InventoryBalanceMapper.class, InventoryPositionMapper.class, InventoryLedgerMapper.class, InventoryOperationMapper.class, InventoryCommandMapper.class, LastlegOrderMapper.class, LastlegOrderCommandRecordMapper.class, LastlegOrderHoldHistoryMapper.class, - LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class)) config.addMapper(mapper); + LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class, + ReturnStockReceiptMapper.class)) config.addMapper(mapper); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); var session = new SqlSessionTemplate(factory.getObject()); var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); @@ -91,6 +93,7 @@ class InventoryHoldConcurrencyPersistenceTest { var sourceLocks = new DispatchPickOrderAttributionService(); ReflectionTestUtils.setField(sourceLocks,"orders",orderMapper); ReflectionTestUtils.setField(sourceLocks,"attributions",session.getMapper(DispatchPdaPickOrderMapper.class)); + ReflectionTestUtils.setField(sourceLocks,"returnReceipts",session.getMapper(ReturnStockReceiptMapper.class)); attribution = proxy(sourceLocks,manager); var target = new LastlegOrderCommandService(); ReflectionTestUtils.setField(target,"orders",orderMapper); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 2bd6e285a..63ac026b0 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 152]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 153]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|152) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|153) AS initialized_tables;$/) } }) @@ -44,6 +44,19 @@ test('车次直接成本按车次幂等并保留独立币种与不可变更正 assert.match(history, /snapshot_json JSON NOT NULL/i) }) +test('退回实收按真实流水唯一保存,重派拣货必须明确唯一来源', () => { + const receipt = table(overseas, 'baiyue_lastleg', 'll_return_stock_receipt') + assert.match(receipt, /UNIQUE KEY uk_return_stock_receipt_ledger\(customer_id,inventory_ledger_id\)/i) + assert.match(receipt, /UNIQUE KEY uk_return_stock_receipt_no\(customer_id,receipt_no\)/i) + assert.match(receipt, /CHECK\(received_packages>0\)/i) + for (const name of ['ll_dispatch_item', 'll_dispatch_pallet_lock', 'll_dispatch_pda_pick_order']) { + assert.match(table(overseas, 'baiyue_lastleg', name), /return_receipt_id BIGINT NULL/i) + } + const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order') + assert.match(pick, /\breceipt_id BIGINT NULL/i) + assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL AND return_receipt_id IS NULL\) OR \(receipt_id IS NULL AND return_receipt_id IS NOT NULL\)\)/i) +}) + test('车辆报价与实测记录保留业务去重和不可变版本', () => { const booking = table(overseas, 'baiyue_lastleg', 'll_fleet_external_booking') assert.match(booking, /quote_source_json JSON NULL/i) -- 2.54.0 From d05ff1cd2675b62204fbc6b2cecbb4772de4ee84 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 16:11:52 +0800 Subject: [PATCH 105/176] test(fleet): cover partial returns and redispatch through real API flow --- ...rn-redispatch-acceptance-contract.test.mjs | 164 ++++++++++++++ .../fleet-return-redispatch-acceptance.mjs | 204 ++++++++++++++++++ scripts/fleet-test-env-acceptance.mjs | 22 +- scripts/fleet-test-env-acceptance.test.mjs | 8 + 4 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs new file mode 100644 index 000000000..3c0ba662e --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -0,0 +1,164 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { selectReturnSources, verifyFleetReturnRedispatch } from './fleet-return-redispatch-acceptance.mjs' + +function fixture(fault = '') { + const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] + let returned = false, originalSigned = false, newSigned = false, departed = false + const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: name, inboundOrderId: String(index + 10), inboundCode: `IN-${name}`, + deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] })) + const tail = name => ({ orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, + subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' + : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) + const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null } + : { deliveryNo: 'DL-ORIGINAL', dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: 'DELIVERING', packageCount: 7, + signedPackages: originalSigned ? 5 : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : 'original-signature' : null } + const envelope = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request-id', traceId: 'trace-id' }) + const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: 'C', warehouseId: 'W', tripNo: trip.tripNo, departure: { status: 'IN_TRANSIT', dispatches: [{ dispatchNo: 'DP-ORIGINAL' }] } } }, + assert() {}, async checkpoint() { checkpoints.push(structuredClone(trip.returnRedispatch)) }, + async requestMultipart(method, path, file) { requests.push({ method, path, file: file.name }); return envelope({ fileId: '99' }) }, + async request(method, path, body, token) { + requests.push({ method, path, body: structuredClone(body), token }) + if (path.startsWith('/api/platform/inbounds?')) { + const code = new URLSearchParams(path.split('?')[1]).get('inboundCode'), name = code.slice(-1) + const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned + return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) + } + if (path.includes('/orders/')) return envelope({ order: tail(path.split('/orders/')[1].split('?')[0]) }) + if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) + if (path.includes('/mark-return')) return envelope({ exceptionNo: 'EX-NEW', version: 0 }) + if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 }) + if (path.includes('/exception-orders/') && path.includes('/complete')) { + const replayed = returned; returned = true + if (fault === 'unknown-return') throw Error('connection lost after commit') + return envelope({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', replayed }) + } + if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } + if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode }) + if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) + if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: 'A', packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) + if (path.endsWith('/drafts/containers')) return envelope({ id: '400' }) + if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) } + if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) } + if (path.endsWith('/fleet/trips/TR-NEW')) return envelope({ trip: { vehiclePlate: 'NEW', driverName: 'driver', driverPhone: 'phone' } }) + if (path.endsWith('/transfer-delivery')) return envelope({ deliveries: [{ deliveryNo: 'DL-NEW' }] }) + if (path.endsWith('/depart')) { assert.equal(token, 'private-pda-token'); const replayed = departed; departed = true; return envelope({ tripNo: 'TR-NEW', shippedPackages: 2, replayed }) } + if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/delivery-orders/')[1].split('?')[0]) }) + throw Error(`Unexpected ${method} ${path}`) + } } + const dependencies = { + async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true }, + async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, + async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, + } + const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } + return { context, trip, input, dependencies, requests, checkpoints } +} +const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) + +test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => { + const f = fixture(), result = await run(f) + assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') + assert.equal(result.sourceEventsVerified, false) + assert.equal(result.readOnlyEvidenceRequired.length, 3) + assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY') + assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') + assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED') + assert.equal(result.afterRedispatch.departed.shippedPackages, 2) + assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')) + assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256)) +}) +for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) { + test(`rejects ${fault} instead of declaring redispatch complete`, async () => { + const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') + if (fault === 'missing-B') assert.ok(f.requests.every(row => row.method === 'GET')) + }) +} +test('unknown return outcome persists the exact original key and cannot automatically restart', async () => { + const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/) + const last = f.checkpoints.at(-1).requests.at(-1) + assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) + const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) +test('pre-send checkpoint failure cannot send even the first HTTP request', async () => { + const f = fixture(); f.context.checkpoint = async () => { throw Error('disk full') } + await assert.rejects(run(f), /disk full/); assert.equal(f.requests.length, 0) +}) +test('fixture selection refuses partial coverage or borrowed source shipments', () => { + const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, + relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } }) + assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/) +}) + +test('WAIT_DELIVERY start submits exactly the version read from the current delivery', async () => { + const f = fixture(), original = f.context.request.bind(f.context) + let started = false + f.context.request = async (method, path, body, ...args) => { + if (method === 'PUT' && path.includes('/delivery-orders/DL-ORIGINAL/status')) { + assert.equal(body.expectedVersion, 17); assert.equal(body.status, 'DELIVERING'); started = true + f.requests.push({ method, path, body }) + return { http: 200, body: { code: '200', data: { deliveryStatus: 'DELIVERING' } }, requestId: 'r', traceId: 't' } + } + const response = await original(method, path, body, ...args) + if (!started && response.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') Object.assign(response.body.data.order, { status: 'WAIT_DELIVERY', version: '17' }) + if (path.includes('/return-inbound-options')) assert.equal(started, true) + return response + } + await run(f) + assert.equal(f.requests.filter(row => row.method === 'PUT' && row.path.includes('/status')).length, 1) +}) + +for (const multipart of [false, true]) test(`http:0 records UNKNOWN_RESULT and stops without retry; multipart=${multipart}`, async () => { + const f = fixture(), original = f.context.request.bind(f.context) + if (multipart) f.context.requestMultipart = async (method, path, file) => { + f.requests.push({ method, path, file: file.name }); return { http: 0, body: null, requestId: null, traceId: null } + } + else f.context.request = async (method, path, body, ...args) => { + if (path.includes('/exception-orders/') && path.includes('/complete')) { + f.requests.push({ method, path, body: structuredClone(body) }); return { http: 0, body: null, requestId: null, traceId: null } + } + return original(method, path, body, ...args) + } + await assert.rejects(run(f), /transport outcome unknown/) + const last = f.checkpoints.at(-1).requests.at(-1) + assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, 0) + if (multipart) assert.match(last.file.sha256, /^[a-f0-9]{64}$/) + else assert.ok(last.body.idempotencyKey) + assert.equal(f.requests.filter(row => row.path === last.path && row.method === last.method).length, 1) + const count = f.requests.length + await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) + +for (const [name, response] of Object.entries({ + server500: { http: 500, body: { code: 'B0001', message: 'Internal error' }, requestId: 'r', traceId: 't' }, + proxy502: { http: 502, body: null, requestId: null, traceId: null }, + malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, + htmlFailure: { http: 403, body: 'proxy blocked', requestId: 'r', traceId: 't' }, + uncorrelatedSuccess: { http: 200, body: { code: '200', data: { status: 'COMPLETED' } }, requestId: null, traceId: null }, +})) test(`ambiguous ${name} persists UNKNOWN_RESULT without replay or further requests`, async () => { + const f = fixture(), original = f.context.request.bind(f.context) + f.context.request = async (method, path, body, ...args) => { + if (path.includes('/mark-return')) { f.requests.push({ method, path, body: structuredClone(body) }); return response } + return original(method, path, body, ...args) + } + await assert.rejects(run(f), /outcome unknown/) + const last = f.checkpoints.at(-1).requests.at(-1) + assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, response.http) + assert.ok(last.body.idempotencyKey) + assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) + assert.equal(f.requests.at(-1).path, last.path) + const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) + +test('correlated explicit business rejection remains RESPONDED and also stops without replay', async () => { + const f = fixture(), original = f.context.request.bind(f.context) + f.context.request = async (method, path, body, ...args) => { + if (path.includes('/mark-return')) { + f.requests.push({ method, path, body }); return { http: 409, body: { code: 'A0409', message: 'version conflict' }, requestId: 'r', traceId: 't' } + } + return original(method, path, body, ...args) + } + await assert.rejects(run(f), /HTTP failed/) + assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED') + assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) +}) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs new file mode 100644 index 000000000..54f6bfb61 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -0,0 +1,204 @@ +import { randomUUID, createHash } from 'node:crypto' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' + +const BASE = '/api/platform/v1/last-leg' +const id = value => value == null ? '' : String(value) +const key = () => `redispatch-${randomUUID()}` +const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` +const ok = response => response?.http === 200 && String(response?.body?.code) === '200' +const copy = value => value === undefined ? undefined : structuredClone(value) + +function definiteResponse(response) { + const http = response?.http, body = response?.body + if (!Number.isInteger(http) || http < 200 || http >= 500 || !body || typeof body !== 'object' || Array.isArray(body) + || !response.requestId || !response.traceId) return false + const code = id(body.code) + return http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400) +} + +export function selectReturnSources(rows) { + const domestic = rows.filter(row => row.tail?.orderType === 'FIRST_LEG' && row.tail.sourceInboundId && row.tail.inboundCode === row.relation.inboundCode) + const a = domestic.find(row => Number(row.tail.packageCount) > 2 && row.relation.returnSources?.some(source => Number(source.maxReturnPackages) >= 2)) + const b = domestic.find(row => a && row.tail.sourceInboundId !== a.tail.sourceInboundId) + if (!a || !b) throw Error('Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B') + for (const selected of [a, b]) { + const packages = domestic.filter(row => row.tail.sourceInboundId === selected.tail.sourceInboundId) + .reduce((sum, row) => sum + (row.relation.returnSources || []).reduce((n, source) => n + Number(source.maxReturnPackages), 0), 0) + if (packages !== Number(selected.tail.packageCount)) throw Error('Current departure must cover each selected source completely; no old shipments may fill the fixture') + } + return { a, b } +} + +/** Fresh-run-only stage. Every helper HTTP request is checkpointed before dispatch, including multipart metadata. */ +export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { + const run = context.createdResources?.fleetAcceptance + if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker + || run.tripNo !== trip.tripNo || run.departure?.status !== 'IN_TRANSIT' || !input?.pickerToken + || id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId) + || JSON.stringify((run.departure.dispatches || []).map(row => row.dispatchNo).sort()) !== JSON.stringify([...(input.dispatchNos || [])].sort()) + || !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function') + throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart') + const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2, + readOnlyEvidenceRequired: ['Before redispatch signature: source A completion outbox count=0 and domestic inbound not DELIVERED; source B completion outbox count=1', + 'After redispatch signature and exact replay: A and B each have exactly one OVERSEAS_DELIVERY_COMPLETED outbox event by customer+sourceInboundId; inspect delivery ACK and domestic inbound completion separately', + 'Join return receipt ledger_id and source_outbound_no to immutable RETURN_STOCK_IN; join new picks by return_receipt_id; original shipment and signed facts remain unchanged'] } + const check = (label, condition) => { if (!condition) throw Error(label) } + const io = Object.create(context) + io.assert = (...args) => { context.assert(...args); if (!args[3]) throw Error(args[1]) } + async function invoke(method, path, body, token, file, options) { + const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' } + if (file) entry.file = { name: file.name, type: file.type, bytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } + proof.requests.push(entry); await context.checkpoint() + try { + const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) + if (!definiteResponse(response)) { + Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + await context.checkpoint() + throw Error(`HTTP transport outcome unknown: ${method} ${path}; preserve the original request, do not resend`) + } + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId, result: copy(response?.body?.data) }) + await context.checkpoint() + check(`Missing HTTP correlation: ${method} ${path}`, response?.requestId && response?.traceId) + return response + } catch (error) { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } throw error } + } + io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options) + io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file) + async function request(method, path, body, token) { + const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data + } + const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete, + wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies } + const customerId = id(input.customerId), warehouseId = id(input.warehouseId) + const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId)) + const delivery = number => request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}`, customerId)) + async function domestic(selected) { + const page = await request('GET', `/api/platform/inbounds?${new URLSearchParams({ tenantId: customerId, inboundCode: selected.relation.inboundCode, page: '1', size: '200' })}`) + const matches = (page.records || []).filter(row => row.inboundCode === selected.relation.inboundCode) + check('Domestic source must resolve uniquely through its current inbound code', matches.length === 1 && matches[0].processStatus) + return matches[0] + } + async function domesticBoundary(a, b, allSigned) { + const snapshots = [] + for (let attempt = 0; attempt < 30; attempt++) { + const A = await domestic(a), B = await domestic(b); snapshots.push({ A, B }) + if (!allSigned) check('Incomplete A must never be marked delivered domestically', A.processStatus !== 'DELIVERED') + if (B.processStatus === 'DELIVERED' && (!allSigned || A.processStatus === 'DELIVERED')) return snapshots + if (attempt < 29) await deps.wait(1000) + } + throw Error(allSigned ? 'Domestic source completion has not converged after redispatch signature' : 'Independent source B domestic completion did not converge') + } + try { + const rows = [] + proof.originalDeliveries = [] + for (const number of [...new Set(input.deliveryNos)]) { + const current = await delivery(number) + check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo)) + proof.originalDeliveries.push(copy(current.order)) + if (current.order.status === 'WAIT_DELIVERY') { + check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version)) + && current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0) + const started = await request('PUT', scoped(`${BASE}/delivery-orders/${number}/status`, customerId), { + status: 'DELIVERING', expectedVersion: Number(current.order.version), remark: `${context.marker} current-run delivery underway`, + }) + check('Return candidates are opened only after formal delivery start', started.deliveryStatus === 'DELIVERING') + } + const relations = await request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}/return-inbound-options`, customerId)) + for (const relation of relations) { + check('Return source relation belongs to the original delivery', relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo) + const detail = await tail(relation) + check('Tail source customer and warehouse match this run', id(detail.order.customerId) === customerId && id(detail.order.overseasWarehouseId) === warehouseId) + rows.push({ relation, tail: detail.order, delivery: current.order }) + } + } + const { a, b } = selectReturnSources(rows) + proof.domesticBefore = { A: await domestic(a), B: await domestic(b) } + check('Both source inbounds must be unfinished in this fresh sample', proof.domesticBefore.A.processStatus !== 'DELIVERED' && proof.domesticBefore.B.processStatus !== 'DELIVERED') + proof.sources = { A: copy(a), B: copy(b) }; await context.checkpoint() + const source = a.relation.returnSources.find(row => Number(row.maxReturnPackages) >= 2) + const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), { + inboundOrderId: a.relation.inboundOrderId, reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', + returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(), + }) + proof.exceptionNo = marked.exceptionNo + const exceptionPath = `${BASE}/exception-orders/${encodeURIComponent(marked.exceptionNo)}` + const resource = `/api/platform/v1/overseas-warehouse-resources/${warehouseId}` + const palletCode = `RET-${randomUUID()}` + const palletId = id(await request('POST', `${resource}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker })) + const pallet = await request('GET', `${resource}/pallets/${palletId}`) + const locationPage = await request('GET', `${resource}/locations?page=1&size=200`) + const location = (Array.isArray(locationPage) ? locationPage : locationPage.records || []).find(row => id(row.id) === id(pallet.locationId)) + check('New return pallet has a real same-warehouse location', id(pallet.warehouseId) === warehouseId && location?.locationCode && pallet.palletCode === palletCode) + const started = await request('POST', scoped(`${exceptionPath}/start-processing`, customerId), { expectedVersion: marked.version, idempotencyKey: key(), handlingAction: '实际退回2箱' }) + const uploaded = await io.requestMultipart('POST', scoped(`${exceptionPath}/evidence`, customerId), { + name: `${context.marker}-return.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} synthetic evidence of 2 returned boxes`), + }) + check('Return evidence uploaded through owned exception API', ok(uploaded) && uploaded.body.data?.fileId) + const receipt = { expectedVersion: started.version, idempotencyKey: key(), handlingResult: '原出库2箱实际退回入仓', evidenceFileIds: [uploaded.body.data.fileId], + receipts: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, receivedPackages: 2, + targetPalletId: palletId, targetPalletNo: palletCode, targetLocationId: location.id, targetLocationCode: location.locationCode }] } + const restored = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) + check('Exactly two boxes restored', restored.status === 'COMPLETED' && Number(restored.restoredPackages) === 2) + const replay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) + check('Same return key replays the same adjustment', replay.replayed === true && id(replay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId)) + proof.returned = { palletId, palletCode, adjustmentId: restored.inventoryAdjustmentId } + proof.originalSignatures = [] + for (const [index, number] of input.deliveryNos.entries()) { + const current = await delivery(number) + const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index) + check('Original remaining cargo actually signed', signed?.order?.signedAt) + proof.originalSignatures.push(copy(signed.order)) + } + const beforeA = await tail(a.relation), beforeB = await tail(b.relation) + check('A remains incomplete while independently complete B is delivered', !beforeA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && beforeB.order.subOrderStatus?.includes('DELIVERY:DELIVERED')) + proof.beforeRedispatch = { A: beforeA, B: beforeB } + proof.domesticBeforeRedispatch = await domesticBoundary(a, b, false) + const candidates = await request('GET', `${BASE}/dispatches/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: a.relation.inboundCode })}`) + const matches = candidates.filter(row => row.sourceType === 'RETURN_RECEIPT' && id(row.warehousePalletId) === palletId && row.returnReceiptNo && id(row.orderId) === id(a.relation.lastMileOrderId)) + check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].packageCount) === 2 && !matches[0].planPalletId) + const selected = matches[0], draftToken = key() + const container = await request('POST', `${BASE}/dispatches/drafts/containers`, { draftToken, capacity: 30 }) + await request('POST', `${BASE}/dispatches/draft-locks/lock`, { sourceType: 'RETURN_RECEIPT', returnReceiptId: selected.returnReceiptId, draftToken, draftContainerId: container.id }) + const arrange = { planPalletIds: [], returnReceiptIds: [selected.returnReceiptId], draftToken } + const arranged = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) + check('Redispatch is a new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) && Number(arranged.packageCount) === 2) + const arrangedReplay = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) + check('Draft replay keeps the same redispatch', arrangedReplay.dispatchNo === arranged.dispatchNo) + const newTrip = await deps.prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId) + proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = selected.returnReceiptNo; proof.redispatchNo = arranged.dispatchNo + const tripDetail = await request('GET', `${BASE}/fleet/trips/${newTrip.tripNo}`) + const transfer = await request('POST', `${BASE}/dispatches/${arranged.dispatchNo}/transfer-delivery`, { + idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), deliveryMethod: 'TRUCK_PALLET', + pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', + vehiclePlate: tripDetail.trip.vehiclePlate, driverName: tripDetail.trip.driverName, driverPhone: tripDetail.trip.driverPhone, appointmentAccount: 'SELF', + fees: [{ feeType: 'DELIVERY_FEE', feeName: '退回重派验收费用', direction: 'RECEIVABLE', currency: 'CNY', amount: 1 }], remark: context.marker, + }) + check('Redispatch produces a new single destination delivery', transfer.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)) + check('Fresh return stock can be picked via PDA', await deps.pickAll(io, input.pickerToken, arranged.dispatchNo)) + const recordVersion = await deps.confirmFleetLoading(io, module, newTrip) + const departBody = { recordVersion, idempotencyKey: key(), remark: '退回2箱重新实际发车' } + const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart` + const departed = await request('POST', departPath, departBody, input.pickerToken) + check('New DEPART ships exactly two returned boxes', departed.tripNo === newTrip.tripNo && Number(departed.shippedPackages) === 2) + const departedAgain = await request('POST', departPath, departBody, input.pickerToken) + check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2) + const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) + const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271) + check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2) + const afterA = await tail(a.relation), afterB = await tail(b.relation) + check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus) + for (const original of proof.originalSignatures) { + const current = (await delivery(original.deliveryNo)).order + check('Original signed facts stay immutable', current.signedAt === original.signedAt && Number(current.signedPackages) === Number(original.signedPackages) && Number(current.packageCount) === Number(original.packageCount)) + } + proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed } + proof.domesticAfterRedispatch = await domesticBoundary(a, b, true) + proof.closure = await deps.finishFleetTrip(io, module, newTrip) + check('New trip cargo and resource lifecycle completes', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + proof.status = 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'; proof.sourceEventsVerified = false + await context.checkpoint(); return proof + } catch (error) { proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error } +} diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 471b485ad..942946248 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -12,6 +12,7 @@ import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-exter import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' +import { verifyFleetReturnRedispatch } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' @@ -23,7 +24,7 @@ const success = response => response?.http === 200 && id(response?.body?.code) = const key = () => `fleet-${randomUUID()}` export function parseFleetArguments(args) { - const config = { execute: false, environment: 'TEST', runId: 'plan' } + const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'handover' } let action = null const seen = new Set() for (let index = 0; index < args.length; index++) { @@ -32,7 +33,7 @@ export function parseFleetArguments(args) { if (action) throw Error('Choose exactly one plan or execute action') action = name; config.execute = name === '--execute'; continue } - if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') + if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') if (seen.has(name)) throw Error('Duplicate argument') seen.add(name) const value = args[++index] @@ -40,16 +41,21 @@ export function parseFleetArguments(args) { else if (name === '--run-id') config.runId = value else if (name === '--output') config.outputDir = value else if (name === '--resume-report') config.resumeReport = value + else if (name === '--scenario') config.scenario = value else config.resumeStage = value } if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') + if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport)) + throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint') return config } export function fleetExecutionPlan(config) { - return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, + return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover', + returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full', + stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', @@ -430,6 +436,13 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol }) stage('RECEIPTS_AND_RESOURCE_RELEASE') proof.receipts = [] + if (context.context.fleetScenario === 'return-redispatch') { + stage('RETURN_REDISPATCH') + const returned = await verifyFleetReturnRedispatch(context, MODULE, trip, { customerId, warehouseId, pickerToken: picker.token, + dispatchNos, deliveryNos: [...partial.proof.deliveryNos, external.deliveryNo] }) + proof.receipts = returned.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + context.context.saveFleetReturnRedispatchFixture(returned) + } else { for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, undefined, '签收前读取本次实际派送') @@ -437,6 +450,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) } + } proof.closure = await finishFleetTrip(context, MODULE, trip) check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') stage('HANDOVER_RESOURCE_CLEANUP') @@ -485,7 +499,9 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) context = createContext ? createContext(options) : new AcceptanceRun(options) context.context.fleetRunId = config.runId + context.context.fleetScenario = config.scenario || 'handover' context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) + context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture) let handoverCheckpoint = 0 context.context.checkpointFleetHandover = proof => { const payload = JSON.stringify(redactEvidence({ marker: context.marker, runId: config.runId, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index e7f7a7713..7cf1e9561 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -18,6 +18,14 @@ test('fleet entry defaults to a no-side-effect plan and requires explicit TEST a for (const output of ['reports/../outside', 'C:/temp/output', 'reports', 'reports/nested/path']) assert.throws(() => parseFleetArguments(['--output', output])) }) +test('return redispatch is explicit, fresh-only and declares source-event proof separately', () => { + const config = parseFleetArguments(['--plan', '--environment', 'TEST', '--run-id', 'return-new', '--scenario', 'return-redispatch']) + const plan = fleetExecutionPlan(config) + assert.equal(plan.scenario, 'return-redispatch'); assert.ok(plan.returnRedispatch.required.includes('Two current-run domestic sources')) + assert.throws(() => parseFleetArguments(['--scenario', 'return-redispatch', '--resume-report', 'reports/old/report.json', '--resume-stage', 'FULL_COVERAGE']), /fresh run/) + assert.throws(() => parseFleetArguments(['--scenario', 'unknown'])) +}) + test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { const order = [], departure = { tripNo: 'TR-NEW', status: 'IN_TRANSIT' } const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, -- 2.54.0 From e49ad96cc468563cd9023a34e9dddc803d02e6c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 16:38:37 +0800 Subject: [PATCH 106/176] feat(fleet): attach scoped evidence to measured trip records --- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 8 +- .../file/service/BusinessFileStorage.java | 4 +- .../fleet-trip-operation-evidence-v1.yaml | 12 ++ .../TripOperationEvidenceFileTest.java | 79 ++++++++ lastleg-service/AGENTS.md | 2 +- .../HttpTripOperationsEvidenceGateway.java | 71 +++++++ ...latformTripOperationsRecordController.java | 21 +- .../TripOperationsEvidenceGateway.java | 9 + .../reporting/TripOperationsRecordModels.java | 13 +- .../TripOperationsRecordService.java | 44 +++- .../openapi/fleet-operations-v1.yaml | 190 +++++++++++++++++- ...HttpTripOperationsEvidenceGatewayTest.java | 25 +++ ...TripOperationsRecordAuthorizationTest.java | 35 ++++ .../TripOperationsRecordPersistenceTest.java | 47 ++++- scripts/README.md | 2 +- ...-company-cost-acceptance-contract.test.mjs | 9 + .../fleet-quotes-operations-contract.test.mjs | 6 +- .../fleet-quotes-operations.mjs | 4 +- scripts/fleet-company-cost-test-env.mjs | 5 +- scripts/synchronous-dependency-baseline.mjs | 15 ++ .../synchronous-dependency-contract.test.mjs | 9 +- 22 files changed, 584 insertions(+), 27 deletions(-) create mode 100644 file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 75e2f76ea..33f23e85c 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -20,6 +20,7 @@ | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | | 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 | +| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | 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 10a375cfd..7bc39d2e8 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -7,7 +7,8 @@ import java.util.Arrays; /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ enum BusinessFileCategory { - FLEET_DRIVER_LICENSE(false, FormatPolicy.LICENSE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -62,6 +63,9 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { + if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); + } if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); } @@ -105,7 +109,7 @@ enum BusinessFileCategory { return Long.toString(value); } - enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, LICENSE, IMAGE } + enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private static final class Limits { private static final long STANDARD = 10L * 1024 * 1024; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 11ee988ae..2b78410e0 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -66,8 +66,8 @@ public class BusinessFileStorage { } else { if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type)) throw bad("图片附件仅支持JPG、PNG"); - if (formatPolicy == BusinessFileCategory.FormatPolicy.LICENSE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) - throw bad("驾驶证附件仅支持PDF、JPG、PNG"); + if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF_IMAGE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) + throw bad("附件仅支持PDF、JPG、PNG"); extension = TYPES.get(type); if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); verifyContent(file, type); diff --git a/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml b/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml new file mode 100644 index 000000000..1f96cda87 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: 出车实测凭证归属契约 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。 + businessType固定FLEET_TRIP_OPERATION_EVIDENCE,tenantId固定0,businessId固定Lastleg车次内部主键。 + 凭证为选填;上传时只接受非空PDF/JPG/PNG,最大10MiB,校验MIME与内容签名。 + 文件按平台、业务类型、车次及文件ID隔离;Lastleg持有当前/历史实测版本的附件引用及读写权限。 + 上传不自动保存实测记录,也不变更出车、库存、费用或签收事实;历史对象不硬删除。 + Lastleg读取时先确认附件属于可见实测版本,再读取内容;不返回内部存储路径或匿名文件URL。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java new file mode 100644 index 000000000..0cac6cc4c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java @@ -0,0 +1,79 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +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 org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TripOperationEvidenceFileTest { + @TempDir Path root; + private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE"; + + @Test void platformTripOwnershipIsRequiredAndDistinctFromOtherFleetFiles() { + var category = BusinessFileCategory.require(TYPE); + category.validateOwner(0); + for (long customer : new long[]{-1, 1, 900}) + assertThatThrownBy(() -> category.validateOwner(customer)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + assertThat(category.storageBusinessId("71")).isEqualTo("71"); + for (String id : new String[]{"0", "-1", "TRIP-71", "../71"}) + assertThatThrownBy(() -> category.storageBusinessId(id)).isInstanceOf(BizException.class); + assertThat(category).isNotEqualTo(BusinessFileCategory.FLEET_DRIVER_LICENSE) + .isNotEqualTo(BusinessFileCategory.FLEET_VEHICLE_RECORD_PHOTO); + } + + @Test void storesPdfAndImagesAndRejectsEmptySpoofedOrOversizeContent() throws Exception { + var storage = storage(); + var pdf = new MockMultipartFile("file", "里程凭证.pdf", "application/pdf", "%PDF-1.7\nmeasured".getBytes()); + var png = new MockMultipartFile("file", "油表.png", "image/png", image("png")); + var jpeg = new MockMultipartFile("file", "里程.jpg", "image/jpeg", image("jpeg")); + for (var file : new MockMultipartFile[]{pdf,png,jpeg}) { + var saved = storage.store(0,"71",file,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + } + for (var bad : new MockMultipartFile[]{ + new MockMultipartFile("file","empty.pdf","application/pdf",new byte[0]), + new MockMultipartFile("file","fake.pdf","application/pdf","text".getBytes()), + new MockMultipartFile("file","fake.png","image/png","text".getBytes()), + new MockMultipartFile("file","notes.txt","text/plain","text".getBytes())}) + assertThatThrownBy(() -> storage.store(0,"71",bad,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)).isInstanceOf(BizException.class); + MultipartFile oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); when(oversized.getSize()).thenReturn(10L*1024*1024+1); + assertThatThrownBy(() -> storage.store(0,"71",oversized,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized,never()).getInputStream(); + } + + @Test void uploadPreservesTripScopeAndWrongOwnerOrMissingBindingNeverReadsBytes() { + var mapper = mock(FileObjectMapper.class); var storage = storage(); + var service = new InternalBusinessFileService(); + ReflectionTestUtils.setField(service,"files",mapper); ReflectionTestUtils.setField(service,"storage",storage); + when(mapper.insert(any(FileObjectEntity.class))).thenAnswer(call->{((FileObjectEntity)call.getArgument(0)).setId(501L);return 1;}); + var upload = new MockMultipartFile("file","record.pdf","application/pdf","%PDF-1.7\nrecord".getBytes()); + var metadata = service.upload(0,TYPE,"71",7,upload); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); assertThat(metadata.businessType()).isEqualTo(TYPE); + assertThat(metadata.businessId()).isEqualTo("71"); assertThat(metadata.fileId()).isEqualTo(501L); + assertThatThrownBy(()->service.metadata(1,TYPE,"71",501)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + when(mapper.selectOne(any())).thenReturn(null); + assertThatThrownBy(()->service.download(0,TYPE,"72",501)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + + private BusinessFileStorage storage() { + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); + storage.initializeRoot(); return storage; + } + + private byte[] image(String format) throws java.io.IOException { + var bytes = new java.io.ByteArrayOutputStream(); + var image = new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_RGB); + assertThat(javax.imageio.ImageIO.write(image,format,bytes)).isTrue(); + return bytes.toByteArray(); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 3ce3b32a3..3424cec54 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -76,7 +76,7 @@ ## 海外仓全流程开发路由 - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 -- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java new file mode 100644 index 000000000..4e6d26004 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +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; +import java.util.Objects; +import java.util.Set; + +@Component +public class HttpTripOperationsEvidenceGateway implements TripOperationsEvidenceGateway { + private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE"; + private static final String PATH = "/internal/file/v1/business-files/0/FLEET_TRIP_OPERATION_EVIDENCE/{trip}"; + private static final long MAX_SIZE = 10L * 1024 * 1024; + private final RestClient client; + private final String token; + public HttpTripOperationsEvidenceGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token; + } + @Override public TripOperationsRecordModels.EvidenceFile upload(long tripId, String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("实测凭证须为非空 PDF、JPG 或 PNG,最大 10MiB"); + var body = new MultipartBodyBuilder(); + try { body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(MediaType.parseMediaType(file.getContentType())); } + catch (IOException error) { throw bad("无法读取实测凭证"); } + var result = client.post().uri(PATH, tripId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class); + return verified(result, tripId, null); + } + @Override public TripOperationsRecordModels.EvidenceFile metadata(long tripId, long fileId) { + return verified(client.get().uri(PATH + "/{file}", tripId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), tripId, fileId); + } + @Override public byte[] download(long tripId, long fileId) { + var response = client.get().uri(PATH + "/{file}/content", tripId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class); + return imageContent(response); + } + static byte[] imageContent(ResponseEntity response) { + byte[] content = response.getBody(); + if (content == null || content.length == 0 || content.length > MAX_SIZE + || !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + return content; + } + static TripOperationsRecordModels.EvidenceFile verified(Envelope result, long tripId, Long fileId) { + if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var data = result.data(); + if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) + || !TYPE.equals(data.businessType()) || !Long.toString(tripId).equals(data.businessId()) + || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() + || !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) + throw bad("文件不属于当前车次或不是有效凭证"); + return new TripOperationsRecordModels.EvidenceFile(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); + } + private static boolean image(String type) { return Set.of("application/pdf", "image/jpeg", "image/png").contains(Objects.toString(type, "")); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "实测凭证不可用或归属校验失败"); } + record Envelope(String code, Metadata data) { } + 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/fleet/reporting/PlatformTripOperationsRecordController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java index 9d764b8c1..4f1eb6bb8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java @@ -10,19 +10,38 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ContentDisposition; @RestController @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record") public class PlatformTripOperationsRecordController { private final TripOperationsRecordService service; public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;} + @PostMapping(value="/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@RequestPart("file") MultipartFile file){ + return ApiResponse.success(service.upload(tripNo,operator(user),file)); + } + @GetMapping("/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ResponseEntity download(@PathVariable String tripNo,@PathVariable @Positive long fileId){ + var value=service.download(tripNo,fileId); + return ResponseEntity.ok().contentType(MediaType.parseMediaType(value.file().contentType())) + .header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.inline().filename(value.file().fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()).body(value.content()); + } @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") public ApiResponse get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));} @GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") public ApiResponse> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));} @PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){ + return ApiResponse.success(service.save(tripNo,operator(user),request)); + } + private static String operator(AuthenticatedUser user){ if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录"); - return ApiResponse.success(service.save(tripNo,user.userId().toString(),request)); + return user.userId().toString(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java new file mode 100644 index 000000000..81abe6d1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.web.multipart.MultipartFile; + +public interface TripOperationsEvidenceGateway { + TripOperationsRecordModels.EvidenceFile upload(long tripId,String operator,MultipartFile file); + TripOperationsRecordModels.EvidenceFile metadata(long tripId,long fileId); + byte[] download(long tripId,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java index f82624ace..b252e4443 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java @@ -4,16 +4,25 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.Instant; +import java.util.List; public final class TripOperationsRecordModels { private TripOperationsRecordModels() { } public record Details( @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles, @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres, - @Size(max=1000) String remark) { } + @Size(max=1000) String remark, List evidenceFiles) { + public Details { evidenceFiles=evidenceFiles==null?List.of():List.copyOf(evidenceFiles); } + } + public record EvidenceFile(String fileId,String fileName,String contentType,long sizeBytes) { } + public record Input(@DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualDistanceMiles, + @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualFuelLitres, + @Size(max=1000) String remark,@Size(max=20) List<@NotNull @Positive Long> evidenceFileIds) { + public Input { evidenceFileIds=evidenceFileIds==null?List.of():List.copyOf(evidenceFileIds); } + } public record Save(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min=8,max=128) String idempotencyKey, - @NotNull @Valid Details details, @Size(max=1000) String reason) { } + @NotNull @Valid Input details, @Size(max=1000) String reason) { } public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { } public record History(long recordVersion, String reason, Instant occurredAt, Details details) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java index 7b6d9ad21..dfe7f7873 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java @@ -13,13 +13,40 @@ import java.nio.charset.StandardCharsets; import java.security.*; import java.time.*; import java.util.HexFormat; +import java.util.List; +import java.util.HashSet; +import com.baiyue.lastleg.fleet.trip.FleetTripMapper; +import org.springframework.web.multipart.MultipartFile; /** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */ @Service public class TripOperationsRecordService { private final TripOperationsRecordRepository repository; private final ObjectMapper json; - public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json) { this.repository=repository;this.json=json; } + private final FleetTripMapper trips; + private final TripOperationsEvidenceGateway files; + public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json,FleetTripMapper trips,TripOperationsEvidenceGateway files) { this.repository=repository;this.json=json;this.trips=trips;this.files=files; } + @Transactional(readOnly=true) + public TripOperationsRecordModels.EvidenceFile upload(String tripNo,String operator,MultipartFile file) { + return files.upload(evidenceTripId(tripNo),operator,file); + } + public record Download(TripOperationsRecordModels.EvidenceFile file,byte[] content) { } + @Transactional(readOnly=true) + public Download download(String tripNo,long fileId) { + long tripId=evidenceTripId(tripNo); + boolean referenced=false; + long total=repository.count(tripNo); + for(int page=1;(long)(page-1)*100read(row.details()).evidenceFiles().stream().anyMatch(file->Long.toString(fileId).equals(file.fileId()))); + } + if(!referenced)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"凭证尚未保存到本车实测记录或历史版本"); + return new Download(files.metadata(tripId,fileId),files.download(tripId,fileId)); + } + private long evidenceTripId(String tripNo) { + var trip=trips.findByNo(tripNo); + if(trip==null || trip.getDepartedAt()==null)throw conflict("仅实际已发车的车次可维护实测凭证"); + return trip.getId(); + } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public TripOperationsRecordModels.Record get(String tripNo) { requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo)); @@ -33,21 +60,28 @@ public class TripOperationsRecordService { @Transactional(isolation=Isolation.READ_COMMITTED) public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) { requireDeparture(tripNo,true); - var d=request.details();var details=new TripOperationsRecordModels.Details(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark())); + var d=request.details(); + if(d.evidenceFileIds().size()>20 || new HashSet<>(d.evidenceFileIds()).size()!=d.evidenceFileIds().size() + || d.evidenceFileIds().stream().anyMatch(id->id==null||id<=0))throw conflict("每次最多绑定20个不同的实测凭证"); + var input=new TripOperationsRecordModels.Input(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark()),d.evidenceFileIds()); String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录"; - String contents=write(details);String hash=hash(request.recordVersion()+":"+contents+":"+reason); + String hash=hash(request.recordVersion()+":"+write(input)+":"+reason); var replay=repository.replay(tripNo,operator,request.idempotencyKey()); if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);} var previous=repository.latest(tripNo);long version=previous==null?0:previous.version(); if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正"); + long tripId=input.evidenceFileIds().isEmpty()?0:evidenceTripId(tripNo); + var evidence=input.evidenceFileIds().stream().map(fileId->files.metadata(tripId,fileId)).toList(); + var details=new TripOperationsRecordModels.Details(input.actualDistanceMiles(),input.actualFuelLitres(),input.remark(),evidence); + String contents=write(details); if(previous!=null && previous.details().equals(contents))return response(tripNo,previous); - if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null)return response(tripNo,null); + if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null && details.evidenceFiles().isEmpty())return response(tripNo,null); var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC)); repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row); } private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");} private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) { - return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null),null) + return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null,List.of()),null) :new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC)); } private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}} diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index 4f43b2d5c..aa4fb9ffa 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -320,7 +320,7 @@ } }, "put": { - "description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。", + "description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。;附件选填,最多20个,保存前验证平台0、FLEET_TRIP_OPERATION_EVIDENCE和本车trip.id归属;幂等键包含完整文件ID列表,成功重放返回原快照,移除当前引用保留历史。", "requestBody": { "required": true, "content": { @@ -338,7 +338,7 @@ "maxLength": 128 }, "details": { - "$ref": "#/components/schemas/Measured" + "$ref": "#/components/schemas/MeasuredInput" }, "reason": { "type": "string", @@ -480,6 +480,115 @@ } } } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/files": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "summary": "上传选填实测凭证,不创建实测记录", + "description": "platform:fleet:operations:edit;仅平台0,实际已发车车次;FLEET_TRIP_OPERATION_EVIDENCE+trip.id,PDF/JPG/PNG,非空且最大10MiB;上传失败不创建或重建记录。", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "统一响应data为可信文件信息", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/OperationEvidenceFile" + } + } + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/files/{fileId}/content": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fileId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "get": { + "summary": "按需读取本车实测凭证,历史引用继续可读", + "description": "platform:fleet:operations:list;仅已保存至本车当前或历史版本的引用可读;重验平台0/业务类型/车次内部ID/文件ID,no-store;不逐行预取。", + "responses": { + "200": { + "description": "PDF或图片原始内容", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "文件不可用或归属不符" + } + } + } } }, "components": { @@ -695,12 +804,19 @@ "type": "string", "nullable": true, "maxLength": 1000 + }, + "evidenceFiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OperationEvidenceFile" + } } }, "required": [ "actualDistanceMiles", "actualFuelLitres", - "remark" + "remark", + "evidenceFiles" ] }, "Destination": { @@ -918,6 +1034,74 @@ "unallocatedTrips", "totals" ] + }, + "MeasuredInput": { + "type": "object", + "properties": { + "actualDistanceMiles": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "actualFuelLitres": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "remark": { + "type": "string", + "nullable": true, + "maxLength": 1000 + }, + "evidenceFileIds": { + "type": "array", + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "description": "选填,空数组合法;只提交本车已上传文件ID,保存重新核验归属" + } + }, + "required": [ + "actualDistanceMiles", + "actualFuelLitres", + "remark" + ] + }, + "OperationEvidenceFile": { + "type": "object", + "required": [ + "fileId", + "fileName", + "contentType", + "sizeBytes" + ], + "properties": { + "fileId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string", + "enum": [ + "application/pdf", + "image/jpeg", + "image/png" + ] + }, + "sizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + } + } } } }, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java new file mode 100644 index 000000000..f62806b07 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import static org.assertj.core.api.Assertions.*; + +class HttpTripOperationsEvidenceGatewayTest { + @Test void platformCategoryTripAndFileMustAllMatch() { + assertThat(HttpTripOperationsEvidenceGateway.verified(envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"application/pdf",8),71,81L).fileId()).isEqualTo("81"); + for(var value:java.util.List.of(envelope(9,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"image/png",8), + envelope(0,"FLEET_VEHICLE_RECORD_PHOTO","71",81,"image/png",8),envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","72",81,"image/png",8), + envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",82,"image/png",8),envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"text/html",8), + envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"image/png",10485761))) + assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.verified(value,71,81L)).hasMessageContaining("不属于"); + } + @Test void contentRejectsEnvelopesAndEmptyFilesButAcceptsPdfAndImages() { + assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes()))).hasMessageContaining("不可用"); + assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).body(new byte[0]))).hasMessageContaining("不可用"); + for(var type:java.util.List.of(MediaType.APPLICATION_PDF,MediaType.IMAGE_PNG,MediaType.IMAGE_JPEG)) + assertThat(HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(type).body(new byte[]{1}))).containsExactly(1); + } + private HttpTripOperationsEvidenceGateway.Envelope envelope(long owner,String type,String trip,long file,String mime,long size){ + return new HttpTripOperationsEvidenceGateway.Envelope("200",new HttpTripOperationsEvidenceGateway.Metadata(file,owner,type,trip,"meter.pdf",mime,size)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java new file mode 100644 index 000000000..86aa49c87 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.access.AccessDeniedException; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; + +class TripOperationsRecordAuthorizationTest { + @Test void evidenceReadsAndWritesUseSeparateOperationsPermissions() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformTripOperationsRecordController.class); + var service=context.getBean(TripOperationsRecordService.class); + when(service.download("TR-A",81)).thenReturn(new TripOperationsRecordService.Download(new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",1),new byte[]{1})); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reader","unused",List.of(new SimpleGrantedAuthority("platform:fleet:operations:list")))); + assertThat(controller.download("TR-A",81).getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThatThrownBy(()->controller.upload(null,"TR-A",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.save(null,"TR-A",null)).isInstanceOf(AccessDeniedException.class); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("writer","unused",List.of(new SimpleGrantedAuthority("platform:fleet:operations:edit")))); + assertThatThrownBy(()->controller.download("TR-A",81)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.upload(null,"TR-A",null)).hasMessageContaining("仅平台人员"); + verify(service).download("TR-A",81);verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Configuration @EnableMethodSecurity + static class Config { + @Bean TripOperationsRecordService service(){return mock(TripOperationsRecordService.class);} + @Bean PlatformTripOperationsRecordController controller(TripOperationsRecordService service){return new PlatformTripOperationsRecordController(service);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java index d56fa7759..fed6868e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java @@ -17,16 +17,20 @@ import static org.mockito.Mockito.*; class TripOperationsRecordPersistenceTest { private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service; + private com.baiyue.lastleg.fleet.trip.FleetTripMapper trips; + private TripOperationsEvidenceGateway files; @BeforeEach void setup() throws Exception { var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source); jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)"); jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)"); var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); - repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule())); + trips=mock(com.baiyue.lastleg.fleet.trip.FleetTripMapper.class);files=mock(TripOperationsEvidenceGateway.class); + var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setId(71L);trip.setTripNo("TR-A");trip.setDepartedAt(java.time.LocalDateTime.of(2026,10,1,0,0));when(trips.findByNo("TR-A")).thenReturn(trip); + repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule()),trips,files); var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy(); } - private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);} + private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Input(distance,fuel,null,java.util.List.of()),null);} @Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() { assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero(); assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车"); @@ -53,4 +57,43 @@ class TripOperationsRecordPersistenceTest { assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero(); } + private TripOperationsRecordModels.Save evidence(long version,String key,Long... ids){ + return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Input(null,null,null,java.util.List.of(ids)),null); + } + @Test void optionalEvidenceIsValidatedAndFrozenWithHistoryAndExactReplay() { + var file=new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",8); + when(files.metadata(71,81)).thenReturn(file); + var command=evidence(0,"attach-proof",81L);var saved=service.save("TR-A","1",command); + assertThat(saved.details().evidenceFiles()).containsExactly(file);assertThat(saved.recordVersion()).isEqualTo(1); + service.save("TR-A","1",evidence(1,"clear-proof")); + assertThat(service.get("TR-A").details().evidenceFiles()).isEmpty(); + reset(files); + assertThat(service.save("TR-A","1",command).details().evidenceFiles()).containsExactly(file); + verifyNoInteractions(files); + assertThat(service.history("TR-A",1,20).records().get(1).details().evidenceFiles()).containsExactly(file); + assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"attach-proof",82L))).hasMessageContaining("其他实测内容"); + assertThatThrownBy(()->service.save("TR-A","1",evidence(1,"stale-proof",81L))).hasMessageContaining("已变化"); + } + @Test void duplicateOrForeignEvidenceCannotCreateRecordAndEmptyEvidenceIsLegal() { + assertThat(service.save("TR-A","1",evidence(0,"empty-evidence")).recordVersion()).isZero(); + assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"duplicate-evidence",81L,81L))).hasMessageContaining("不同"); + when(files.metadata(71,81)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"文件不属于当前车次")); + assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"foreign-evidence",81L))).hasMessageContaining("不属于"); + assertThat(repository.count("TR-A")).isZero(); + } + @Test void uploadDoesNotCreateMeasuredRecordAndHistoricalDownloadRevalidatesOwnership() { + var blob=new org.springframework.mock.web.MockMultipartFile("file","meter.pdf","application/pdf",new byte[]{1}); + var file=new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",1); + when(files.upload(71,"1",blob)).thenReturn(file); + assertThat(service.upload("TR-A","1",blob)).isEqualTo(file);assertThat(repository.count("TR-A")).isZero(); + when(files.upload(71,"1",blob)).thenThrow(new IllegalStateException("upload interrupted")); + assertThatThrownBy(()->service.upload("TR-A","1",blob)).hasMessageContaining("interrupted");assertThat(repository.count("TR-A")).isZero(); + assertThatThrownBy(()->service.download("TR-A",81)).hasMessageContaining("尚未保存"); + when(files.metadata(71,81)).thenReturn(file);when(files.download(71,81)).thenReturn(new byte[]{1}); + service.save("TR-A","1",evidence(0,"bind-for-read",81L)); + service.save("TR-A","1",evidence(1,"remove-reference")); + assertThat(service.download("TR-A",81).content()).containsExactly(1); + assertThatThrownBy(()->service.download("TR-A",82)).hasMessageContaining("尚未保存"); + assertThatThrownBy(()->service.upload("TR-B","1",blob)).hasMessageContaining("实际已发车"); + } } diff --git a/scripts/README.md b/scripts/README.md index 8588d99d4..d3d41c8af 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,7 +12,7 @@ `fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 -`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=a6fd0ba3`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 +`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=8700fc34238bc33339351c73c15f3af8de8ce517`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 `fee-void-recovery-test-env.mjs` 是费用作废确认响应丢失的 TEST 专项入口,默认 `--plan` 零网络。执行须显式传入 `--execute --environment TEST --run-id <新批次> --customer-id <测试客户ID> --control-customer-id <另一测试客户ID> --warehouse-id <海外仓ID>`,外部 TEST/Jenkins 配置及 `FBA_CORE_JENKINS_AGENT` 提供凭据与取证节点。创建一条新仓储服务应收,经提交审核和原发布确认后,对该来源版本的 VOIDED 事件注入 ACK 丢失;原事件 DEAD 后受保护重试,核对最终作废、同命令重放、另一客户费用与完整库存不变。不写业务SQL,不使用旧费用作废;故障窗口会临时重建同镜像 Lastleg,禁止并行发布或其他共享环境写入。报告在 `reports/fee-void-<新批次>/`,请求前保存意图,失败保留样本,不自动重跑或覆盖;finally 恢复原服务、停止代理并退出会话,恢复未证明则失败。`fee-void-recovery-test-env.test.mjs` 是离线编排与失败清理检查,不能代替真实环境结果。 diff --git a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs index bab7bab53..83df4d699 100644 --- a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs @@ -123,6 +123,15 @@ test('independent runner requires explicit service readiness and only the specif assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('test-acceptance', 'dev-acceptance')}`]), /授权目录/) assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).endsWith('test-acceptance.env')) }) + +test('readiness gate accepts only the full production anchor, not old anchors, shorthand or another commit', () => { + const profile = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] + assert.equal(READY_COMMIT, '8700fc34238bc33339351c73c15f3af8de8ce517') + for (const commit of ['a6fd0ba3', 'cb6c54ff', '8700fc34', 'd05ff1cd', `${READY_COMMIT}0`]) { + assert.throws(() => requireReady([`--ready-commit=${commit}`], profile), /就绪/) + } + assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], profile).endsWith('test-acceptance.env')) +}) test('company cost verifier exercises CNY/USD, void/rebuild, payment replay and customer isolation without direct transport', async () => { const { context, calls, vehicles, records, costs } = fixture() await run(context, options) diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs index 91822a415..126ce07ce 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs @@ -8,7 +8,7 @@ function harness(fault) { const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = [] let next = 0, supplierStatus = 'ACTIVE' - let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null } } + let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] } } const measuredHistory = [] const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] } const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' }) @@ -75,7 +75,9 @@ function harness(fault) { const prior = measuredKeys.get(body.idempotencyKey) if (prior) { if (fault === 'measured replay adds history') measuredHistory.push(copy(measured)); return response(prior) } if (String(body.recordVersion) !== measured.recordVersion) return response(null, 'A0409') - measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: copy(body.details) } + assert.deepEqual(body.details.evidenceFileIds, []); assert.equal(Object.hasOwn(body.details, 'evidenceFiles'), false) + const { evidenceFileIds, ...values } = body.details + measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: { ...copy(values), evidenceFiles: [] } } measuredKeys.set(body.idempotencyKey, copy(measured)); measuredHistory.unshift(copy(measured)); return response(measured) } if (p === '/api/platform/v1/last-leg/fleet/operations') { diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index fca72635d..640cb3ec6 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -285,12 +285,12 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames const recordPath = `${OPERATIONS}/${encode(fixture.tripNo)}/record` const initial = await command('GET', recordPath, undefined, '核对本轮车次尚无实测记录') check('禁止覆盖已有实测历史样本', null, id(initial.recordVersion) === '0' && initial.details.actualDistanceMiles == null && initial.details.actualFuelLitres == null) - const measured = { recordVersion: initial.recordVersion, idempotencyKey: key('measured-zero'), details: { actualDistanceMiles: 0, actualFuelLitres: null, remark: context.marker }, reason: null } + const measured = { recordVersion: initial.recordVersion, idempotencyKey: key('measured-zero'), details: { actualDistanceMiles: 0, actualFuelLitres: null, remark: context.marker, evidenceFileIds: [] }, reason: null } const first = await command('PUT', recordPath, measured, '登记明确零里程和未知油耗') check('实测明确零值不变成未填写', null, first.details.actualDistanceMiles != null && Number(first.details.actualDistanceMiles) === 0 && first.details.actualFuelLitres == null) const firstAgain = await command('PUT', recordPath, measured, '重放实测保存') check('实测重放保留原版本', null, id(firstAgain.recordVersion) === id(first.recordVersion)) - const corrected = await command('PUT', recordPath, { recordVersion: first.recordVersion, idempotencyKey: key('measured-correct'), details: { actualDistanceMiles: 25.5, actualFuelLitres: 8.2, remark: context.marker }, reason: '核对受控实测' }, '更正实测里程油耗') + const corrected = await command('PUT', recordPath, { recordVersion: first.recordVersion, idempotencyKey: key('measured-correct'), details: { actualDistanceMiles: 25.5, actualFuelLitres: 8.2, remark: context.marker, evidenceFileIds: [] }, reason: '核对受控实测' }, '更正实测里程油耗') await reject('PUT', recordPath, { ...measured, idempotencyKey: key('measured-stale') }, 'A0409', '旧实测版本拒绝') const measuredHistory = await command('GET', `${recordPath}/history?page=1&size=20`, undefined, '读取实测更正历史') check('实测重放与冲突没有追加历史', null, Number(measuredHistory.total) === 2 && version(corrected.recordVersion) === version(first.recordVersion) + 1n) diff --git a/scripts/fleet-company-cost-test-env.mjs b/scripts/fleet-company-cost-test-env.mjs index 1c3b72ece..434795a29 100644 --- a/scripts/fleet-company-cost-test-env.mjs +++ b/scripts/fleet-company-cost-test-env.mjs @@ -6,7 +6,10 @@ import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { run as verifyCompanyCost } from './admin-test-env-modules/fleet-company-cost-acceptance.mjs' export const MARKER = 'FLEET-COST-20261003' -export const READY_COMMIT = 'a6fd0ba3' +// Production source anchor, not a deployment-health assertion. The coordinator must independently +// verify the deployed commit (including any descendant) and service readiness before passing this exact anchor. +// This gate intentionally does not infer ancestry, compare deployed source, or perform network checks. +export const READY_COMMIT = '8700fc34238bc33339351c73c15f3af8de8ce517' export function requireReady(args, execArgs) { if (!args.includes(`--ready-commit=${READY_COMMIT}`)) throw new Error('等待总控确认目标服务就绪后,才可显式传入本轮ready-commit') return requireTestProfile(execArgs) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index bb49494a9..4ccc1769c 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,21 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '公司车辆付款凭证按平台及费用ID校验;上传创建不可变对象且无远端幂等键,读取可重复,禁止传输层自动重试上传'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '按维修/救援来源批量只读查询公司费用状态;POST为查询,不触发入账或付款'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '维修/救援登记读取并校验权威供应商状态,不创建或修改供应商'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '采购报价读取启用卡车供应商及服务范围;内部令牌保护,只读供应商主档'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 0a673d213..7ccbb4500 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -102,7 +102,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, 66) + assert.equal(reviewed.length, 70) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -118,7 +118,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, 24) + assert.equal(reviewed.length, 25) assert.deepEqual(discovered.sort(), reviewed) }) @@ -210,8 +210,11 @@ test('unified authority checks remain infrastructure edges and direct events rem assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) const financeReads = SYNCHRONOUS_CLIENTS.filter(client => client.source === 'lastleg-service' && client.target === 'finance-service' && client.contexts.includes('REQUEST')) - assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]), [[ + assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]) + .sort((left, right) => left[0].localeCompare(right[0])), [[ 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ' + ], [ + 'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', 'READ', 'SAFE_READ' ]]) assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) -- 2.54.0 From 14696fb1a120e358eec390457a50b3b5e8def883 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 17:08:15 +0800 Subject: [PATCH 107/176] test(order): verify foreign customer addresses rejected in XLSX imports --- scripts/README.md | 2 + scripts/client-xlsx-address-probe.py | 195 ++++++++++++++++++ scripts/client-xlsx-address-probe.test.py | 57 +++++ scripts/client-xlsx-address-test-env.mjs | 30 +++ scripts/client-xlsx-address-test-env.test.mjs | 7 + 5 files changed, 291 insertions(+) create mode 100644 scripts/client-xlsx-address-probe.py create mode 100644 scripts/client-xlsx-address-probe.test.py create mode 100644 scripts/client-xlsx-address-test-env.mjs create mode 100644 scripts/client-xlsx-address-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index d3d41c8af..342cfb664 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,8 @@ `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 +`client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表 SQL 零漂移或浏览器通过。对应 Node/Python 测试检查零网络计划、精准拒绝及模板其他部分不变。 + 模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 `client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。 diff --git a/scripts/client-xlsx-address-probe.py b/scripts/client-xlsx-address-probe.py new file mode 100644 index 000000000..078e75237 --- /dev/null +++ b/scripts/client-xlsx-address-probe.py @@ -0,0 +1,195 @@ +"""Two bounded negative imports; preserve evidence and never retry unknown writes.""" +import hashlib +import io +import json +import re +import sys +import urllib.error +import urllib.request +import uuid +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from xml.sax.saxutils import escape +from openpyxl import load_workbook + + +def require(ok, message): + if not ok: + raise AssertionError(message) + + +def options(content, title): + book = load_workbook(io.BytesIO(content)) + try: + sheet = book['订单导入'] + headers = {str(c.value).removesuffix(' *'): c.column for c in sheet[2] if c.value} + require(len(headers) == 31 and title in headers, 'Unexpected template headers') + col = headers[title] + validations = [v for v in sheet.data_validations.dataValidation + if any(r.min_col <= col <= r.max_col and r.min_row <= 3 <= r.max_row for r in v.sqref.ranges)] + if not validations: + return [] + require(len(validations) == 1, 'Ambiguous option range') + defined = book.defined_names[validations[0].formula1] + return [c.value for name, span in defined.destinations for row in book[name][span] for c in row] + finally: + book.close() + + +def workbook(content, row): + require(len(row) == 31, 'Expected 31 cells') + output = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(content)) as source, zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as target: + for item in source.infolist(): + data = source.read(item.filename) + if item.filename == 'xl/worksheets/sheet1.xml': + xml = data.decode() + headers = re.findall(r']*\br="[12]"[^>]*>[\s\S]*?', xml) + require(len(headers) == 2, 'Both original header rows required') + cells = [] + for index, value in enumerate(row): + col = chr(65 + index) if index < 26 else 'A' + chr(65 + index - 26) + cells.append(f'{escape(str(value))}') + xml, count = re.subn(r']*>[\s\S]*?', + '' + ''.join(headers) + '' + ''.join(cells) + '', xml) + require(count == 1, 'One data sheet required') + data = xml.encode() + target.writestr(item, data) + return output.getvalue() + + +def assert_rejection(body, title): + require(str(body.get('code')) == '200', 'Import failed before row-level validation') + data = body.get('data') or {} + result = data.get('result') or {} + require(data.get('mode') == 'PARTIAL' and data.get('sourceFileId') and data.get('errorReportFileId'), 'Missing import file evidence') + require(result.get('total') == 1 and result.get('succeeded') == 0 and result.get('failed') == 1, 'Unexpected creation result') + items = result.get('items') or [] + require(len(items) == 1, 'Expected exactly one error row') + row = items[0] + require(row.get('success') is False and row.get('clientRowId') == 'excel-row-3' + and all(row.get(k) is None for k in ['orderId', 'orderNo', 'inboundCode']) + and row.get('errorCode') == 'A0422' + and row.get('errorMessage') == title + '不存在、已停用或不属于当前客户', 'Not the precise customer-address rejection') + + +def main(config): + require(config.get('environment') == 'TEST' and re.fullmatch(r'[A-Za-z0-9_-]{1,60}', config.get('runId', '')), 'Explicit TEST run required') + directory = Path(__file__).resolve().parent.parent / 'reports' / ('client-xlsx-address-' + config['runId']) + directory.mkdir() + report = {'startedAt': datetime.now(timezone.utc).isoformat(), 'success': False, 'requests': [], 'checks': [], 'sessionsClosed': []} + sessions = {} + + def save(): + (directory / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + opener = urllib.request.build_opener(NoRedirect) + + def request(name, method, path, body=None, binary=False, key=None): + require(method == 'GET' or path in ['/auth/login', '/auth/logout', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL'], 'Unexpected write') + entry = {'session': name, 'method': method, 'path': path, 'outcome': 'PENDING'} + headers = {} + if sessions.get(name, {}).get('accessToken'): + headers['Authorization'] = 'Bearer ' + sessions[name]['accessToken'] + if isinstance(body, bytes): + boundary = 'probe-' + uuid.uuid4().hex + entry['workbookSha256'] = hashlib.sha256(body).hexdigest() + body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="address-probe.xlsx"\r\nContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n'.encode() + + body + f'\r\n--{boundary}--\r\n'.encode()) + headers['Content-Type'] = 'multipart/form-data; boundary=' + boundary + headers['Idempotency-Key'] = key + entry['key'] = key + elif body is not None: + body = json.dumps(body).encode() + headers['Content-Type'] = 'application/json' + report['requests'].append(entry) + save() + try: + response = opener.open(urllib.request.Request(config['base'] + path, body, headers, method=method), timeout=30) + with response: + data = response.read() + entry.update(outcome='RECEIVED', http=response.status, requestId=response.headers.get('x-request-id')) + if binary: + require('spreadsheetml' in response.headers.get('content-type', ''), 'Expected workbook content type') + save() + return data + result = json.loads(data) + entry['code'] = result.get('code') + if key: + imported = result.get('data') or {} + rows = (imported.get('result') or {}).get('items') or [] + entry.update(batchNo=imported.get('batchNo'), sourceFileId=imported.get('sourceFileId'), + errorReportFileId=imported.get('errorReportFileId'), + returnedOrderIds=[r['orderId'] for r in rows if r.get('orderId') is not None], + rowErrorCodes=[r.get('errorCode') for r in rows]) + save() + return result + except Exception: + entry['outcome'] = 'UNKNOWN_STOP' if entry['outcome'] == 'PENDING' else 'RECEIVED_INVALID' + save() + raise + + def success(name, method, path, body=None): + result = request(name, method, path, body) + require(str(result.get('code')) == '200', 'Setup/cleanup failed') + return result['data'] + + try: + identities, templates = [], {} + for name in ['source', 'control']: + sessions[name] = success(name, 'POST', '/auth/login', config[name]) + require(sessions[name].get('accessToken') and sessions[name].get('refreshToken'), 'Incomplete session') + profile = success(name, 'GET', '/merchant/v1/iam/tenant/profile') + require(profile.get('id') is not None, 'Missing customer identity') + identities.append(str(profile['id'])) + templates[name] = request(name, 'GET', '/merchant/v1/orders/first-leg/batch-imports/template', binary=True) + require(identities[0] != identities[1], 'Distinct customers required') + report['customerIdentitySha256'] = [hashlib.sha256(x.encode()).hexdigest() for x in identities] + source, target = templates['source'], templates['control'] + common = {} + for title in ['国内收货仓', '运输渠道', 'FBA目的仓']: + shared = [x for x in options(source, title) if x in options(target, title)] + require(shared, 'No shared valid master-data option') + common[title] = shared[0] + for title in ['取货地址', '私人目的仓地址']: + foreign = [x for x in options(source, title) if x not in options(target, title)] + require(foreign, 'No distinct foreign address option') + row = ['海运', '', '自行快递', common['国内收货仓'], common['运输渠道'], '亚马逊FBA', common['FBA目的仓'], '', '', '', + '地址归属测试', '纸箱', '1', '1', '0.001', '塑料', '否', '否', 'ADDRESS-PROBE', '', '', '', + '买单报关', '包税', '散货拼箱', '否', '', '', '普通头程', '', config['runId']] + if title == '取货地址': + row[2], row[9], row[29] = '平台上门提货', '2026-10-10 10:00:00', foreign[0] + else: + row[5], row[6], row[8] = '私人地址', '', foreign[0] + # Use the actual source template, including its foreign hidden option directory. + payload = workbook(source, row) + result = request('control', 'POST', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL', payload, + key='XADDR-' + config['runId'] + '-' + str(len(report['checks']))) + assert_rejection(result, title) + report['checks'].append({'field': title, 'passed': True, 'foreignOptionSha256': hashlib.sha256(foreign[0].encode()).hexdigest()}) + save() + report['success'] = True + report['limitation'] = 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim' + except Exception as error: + report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry' + finally: + for name, session in sessions.items(): + if session.get('accessToken'): + try: + success(name, 'POST', '/auth/logout', {'refreshToken': session['refreshToken']}) + report['sessionsClosed'].append(name) + except Exception: + report['success'] = False + report['success'] = report['success'] and len(report['sessionsClosed']) == 2 + report['completedAt'] = datetime.now(timezone.utc).isoformat() + save() + print(json.dumps({'success': report['success'], 'checks': len(report['checks'])})) + return report['success'] + + +if __name__ == '__main__': + sys.exit(0 if main(json.load(sys.stdin)) else 1) diff --git a/scripts/client-xlsx-address-probe.test.py b/scripts/client-xlsx-address-probe.test.py new file mode 100644 index 000000000..cfd5149e9 --- /dev/null +++ b/scripts/client-xlsx-address-probe.test.py @@ -0,0 +1,57 @@ +import copy +import importlib.util +import io +from pathlib import Path +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('client-xlsx-address-probe.py')) +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) + + +class AddressProbeTests(unittest.TestCase): + def response(self): + return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e', + 'result': {'total': 1, 'succeeded': 0, 'failed': 1, 'items': [ + {'success': False, 'clientRowId': 'excel-row-3', 'errorCode': 'A0422', + 'errorMessage': '取货地址不存在、已停用或不属于当前客户'}]}}} + + def test_precise_address_rejection(self): + probe.assert_rejection(self.response(), '取货地址') + for change in [{'success': True}, {'orderId': 'created'}, {'errorCode': 'A0404'}, + {'clientRowId': 'excel-row-4'}, {'errorMessage': '国内收货仓不能为空'}]: + changed = copy.deepcopy(self.response()) + changed['data']['result']['items'][0].update(change) + with self.assertRaises(AssertionError): + probe.assert_rejection(changed, '取货地址') + + def test_not_setup_or_partial_success(self): + for part, change in [('root', {'code': 'A0422'}), ('data', {'errorReportFileId': None}), + ('result', {'succeeded': 1}), ('result', {'total': 2})]: + changed = self.response() + value = changed if part == 'root' else changed['data'] if part == 'data' else changed['data']['result'] + value.update(change) + with self.assertRaises(AssertionError): + probe.assert_rejection(changed, '取货地址') + + def test_workbook_preserves_hidden_parts_and_replaces_only_data(self): + raw = io.BytesIO() + sheet = '' + with zipfile.ZipFile(raw, 'w') as z: + z.writestr('xl/worksheets/sheet1.xml', sheet) + z.writestr('xl/worksheets/sheet2.xml', 'source-options') + z.writestr('xl/workbook.xml', 'source-names') + output = probe.workbook(raw.getvalue(), ['<&'] + [''] * 29 + ['marker']) + with zipfile.ZipFile(io.BytesIO(output)) as z: + self.assertEqual(z.read('xl/worksheets/sheet2.xml'), b'source-options') + self.assertEqual(z.read('xl/workbook.xml'), b'source-names') + xml = z.read('xl/worksheets/sheet1.xml').decode() + self.assertIn('r="AE3"', xml) + self.assertIn('<&', xml) + self.assertEqual(xml.count(''), 1) + self.assertIn('', xml) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-xlsx-address-test-env.mjs b/scripts/client-xlsx-address-test-env.mjs new file mode 100644 index 000000000..f7e1012dd --- /dev/null +++ b/scripts/client-xlsx-address-test-env.mjs @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { parseEnv } from 'node:util' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { executionOptions } from './client-workbench-test-env.mjs' +import { customerProfilePath } from './client-import-template-test-env.mjs' + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, + writes: 'Two single-row XLSX PARTIAL imports must reject foreign customer addresses; source/error files may be retained; no retry or deletion' } + if (args.length !== 5 || !args[4].startsWith('--python=')) throw Error('Explicit TEST, customer and bundled Python required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const credentials = parseEnv(await readFile(customerProfilePath(args[3], execArgs[0].slice(11)), 'utf8')) + if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw Error('Missing control credentials') + const python = resolve(args[4].slice(9)) + if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw Error('Use bundled Python') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const config = { environment: 'TEST', base: options.base, runId: options.runId, + source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }, + control: { loginId: credentials.MERCHANT_TEST_USERNAME, password: credentials.MERCHANT_TEST_PASSWORD } } + const child = spawn(python, ['-B', resolve(root, 'scripts/client-xlsx-address-probe.py')], + { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) + child.stdin.end(JSON.stringify(config)) + const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) + return { success: code === 0, report: resolve(root, 'reports', `client-xlsx-address-${options.runId}`, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) + execute().then(r => { console.log(JSON.stringify(r)); if (r.success === false) process.exitCode = 1 }) + .catch(() => { console.error('XLSX probe did not start; check explicit TEST inputs and unused run ID.'); process.exitCode = 1 }) diff --git a/scripts/client-xlsx-address-test-env.test.mjs b/scripts/client-xlsx-address-test-env.test.mjs new file mode 100644 index 000000000..e99e33d2e --- /dev/null +++ b/scripts/client-xlsx-address-test-env.test.mjs @@ -0,0 +1,7 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execute } from './client-xlsx-address-test-env.mjs' +test('XLSX rejection plan performs no network or credential reads', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) + await assert.rejects(execute(['--execute'], [], {})) +}) -- 2.54.0 From 78a4d4a0893a898a9c9543cfe64681103f4b0ea9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 17:12:49 +0800 Subject: [PATCH 108/176] test(order): verify rejected XLSX imports preserve scoped database facts --- scripts/README.md | 2 +- scripts/client-xlsx-address-probe.py | 72 ++++++++++++++++++- scripts/client-xlsx-address-probe.test.py | 28 ++++++++ scripts/client-xlsx-address-test-env.mjs | 16 ++++- scripts/client-xlsx-address-test-env.test.mjs | 8 ++- 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 342cfb664..a8859e8dd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,7 @@ `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 -`client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表 SQL 零漂移或浏览器通过。对应 Node/Python 测试检查零网络计划、精准拒绝及模板其他部分不变。 +`client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表浏览器通过。可追加 `--database-profile=<同目录test-database.env>`:用现有 mysql CLI 的只读一致性事务,在导入前及每次拒绝后读取两客户 Order 的订单、货物、参与方、内部备注、状态历史、幂等和领域 Outbox 七表完整记录摘要(含逻辑删除),严格比较数量与完整摘要;不同、缺表、截断或查询失败即停止。DB 凭据只进入子进程环境,报告不含原始行;文件、审计及认证正常留存不属于这七表不变断言,不声称全环境零写入。未传此参数只验 HTTP。对应 Node/Python 测试检查零网络计划、环境边界、精准拒绝、模板其他部分不变及数据库快照完整性。 模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 diff --git a/scripts/client-xlsx-address-probe.py b/scripts/client-xlsx-address-probe.py index 078e75237..4a5622508 100644 --- a/scripts/client-xlsx-address-probe.py +++ b/scripts/client-xlsx-address-probe.py @@ -2,7 +2,9 @@ import hashlib import io import json +import os import re +import subprocess import sys import urllib.error import urllib.request @@ -74,6 +76,63 @@ def assert_rejection(body, title): and row.get('errorMessage') == title + '不存在、已停用或不属于当前客户', 'Not the precise customer-address rejection') +SNAPSHOT_TABLES = ['ord_order', 'ord_order_cargo', 'ord_order_party', 'ord_order_internal_note', + 'ord_order_status_history', 'ord_idempotency_record', 'ord_outbox_event'] + + +def snapshot_sql(columns, identities): + require(len(identities) == 2 and len(set(identities)) == 2 + and all(re.fullmatch(r'[1-9][0-9]{0,19}', x) for x in identities), 'Invalid customer scope') + queries = ['SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] + for table in SNAPSHOT_TABLES: + names = columns.get(table, []) + require(names and len(names) == len(set(names)) and 'id' in names and 'tenant_id' in names + and all(re.fullmatch(r'[a-z][a-z0-9_]*', n) for n in names), 'Incomplete snapshot schema') + fields = ','.join('CAST(`' + n + '` AS CHAR)' for n in names) + where = 'tenant_id IN (' + ','.join(identities) + ')' + queries.append(f"SELECT JSON_OBJECT('table','{table}','hash',SHA2(CAST(JSON_ARRAY({fields}) AS CHAR),256)) FROM `{table}` WHERE {where} ORDER BY id;") + queries.append(f"SELECT JSON_OBJECT('table','{table}','count',COUNT(*)) FROM `{table}` WHERE {where};") + return '\n'.join(queries + ['COMMIT;']) + + +def snapshot_summary(rows): + hashes = {t: [] for t in SNAPSHOT_TABLES} + counts = {} + for row in rows: + table = row.get('table') + require(table in hashes, 'Unexpected snapshot table') + if 'count' in row: + require(table not in counts and isinstance(row['count'], int) and row['count'] >= 0, 'Invalid snapshot count') + counts[table] = row['count'] + else: + require(re.fullmatch(r'[0-9a-f]{64}', row.get('hash', '')), 'Invalid row digest') + hashes[table].append(row['hash']) + require(len(counts) == len(hashes) and all(len(hashes[t]) == counts[t] for t in hashes), 'Truncated snapshot') + return {t: {'count': counts[t], 'sha256': hashlib.sha256('\n'.join(hashes[t]).encode()).hexdigest()} + for t in SNAPSHOT_TABLES} + + +def database_snapshot(config, identities): + def query(sql): + # SQL is sent via stdin; credentials are inherited only by this child process. + result = subprocess.run(['mysql', '--protocol=TCP', '-h', config['host'], '-P', config['port'], + '-u', config['username'], '--batch', '--raw', '--skip-column-names', + '--default-character-set=utf8mb4', '--database=baiyue_order'], + input=sql, text=True, encoding='utf-8', capture_output=True, timeout=30, + env={**os.environ, 'MYSQL_PWD': config['password']}, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + require(result.returncode == 0, 'Read-only TEST database query failed') + return [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + selected = ','.join("'" + t + "'" for t in SNAPSHOT_TABLES) + metadata = query("SELECT JSON_OBJECT('table',TABLE_NAME,'column',COLUMN_NAME) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA='baiyue_order' AND TABLE_NAME IN (" + selected + ") ORDER BY TABLE_NAME,ORDINAL_POSITION;") + columns = {} + for row in metadata: + columns.setdefault(row['table'], []).append(row['column']) + return snapshot_summary(query(snapshot_sql(columns, identities))) + + def main(config): require(config.get('environment') == 'TEST' and re.fullmatch(r'[A-Za-z0-9_-]{1,60}', config.get('runId', '')), 'Explicit TEST run required') directory = Path(__file__).resolve().parent.parent / 'reports' / ('client-xlsx-address-' + config['runId']) @@ -155,6 +214,9 @@ def main(config): shared = [x for x in options(source, title) if x in options(target, title)] require(shared, 'No shared valid master-data option') common[title] = shared[0] + if config.get('database'): + report['databaseBefore'] = database_snapshot(config['database'], identities) + save() for title in ['取货地址', '私人目的仓地址']: foreign = [x for x in options(source, title) if x not in options(target, title)] require(foreign, 'No distinct foreign address option') @@ -170,10 +232,18 @@ def main(config): result = request('control', 'POST', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL', payload, key='XADDR-' + config['runId'] + '-' + str(len(report['checks']))) assert_rejection(result, title) + if config.get('database'): + after = database_snapshot(config['database'], identities) + report.setdefault('databaseAfterEachImport', []).append({'field': title, 'tables': after}) + save() + require(after == report['databaseBefore'], 'Order-domain database changed after rejected XLSX import') report['checks'].append({'field': title, 'passed': True, 'foreignOptionSha256': hashlib.sha256(foreign[0].encode()).hexdigest()}) save() report['success'] = True - report['limitation'] = 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim' + report['databaseVerified'] = bool(config.get('database')) + report['limitation'] = ('Complete rows in seven Order tables for both customers, including logically deleted rows; ' + 'file/audit/auth writes are outside this unchanged-order-domain assertion; no browser claim' + if config.get('database') else 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim') except Exception as error: report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry' finally: diff --git a/scripts/client-xlsx-address-probe.test.py b/scripts/client-xlsx-address-probe.test.py index cfd5149e9..58009517a 100644 --- a/scripts/client-xlsx-address-probe.test.py +++ b/scripts/client-xlsx-address-probe.test.py @@ -11,6 +11,34 @@ spec.loader.exec_module(probe) class AddressProbeTests(unittest.TestCase): + def test_snapshot_requires_complete_tables_and_row_counts(self): + rows = [{'table': t, 'count': 0} for t in probe.SNAPSHOT_TABLES] + before = probe.snapshot_summary(rows) + for bad in [rows[:-1], rows + [rows[0]], rows + [{'table': probe.SNAPSHOT_TABLES[0], 'hash': 'a' * 64}]]: + with self.assertRaises(AssertionError): + probe.snapshot_summary(bad) + changed = copy.deepcopy(rows) + changed[0]['count'] = 1 + changed.append({'table': probe.SNAPSHOT_TABLES[0], 'hash': 'a' * 64}) + after = probe.snapshot_summary(changed) + self.assertNotEqual(before, after) + changed[-1]['hash'] = 'b' * 64 + self.assertNotEqual(after, probe.snapshot_summary(changed)) + + def test_snapshot_sql_is_read_only_complete_and_customer_scoped(self): + columns = {t: ['id', 'tenant_id', 'deleted', 'version'] for t in probe.SNAPSHOT_TABLES} + sql = probe.snapshot_sql(columns, ['123', '456']) + self.assertIn('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY', sql) + self.assertEqual(sql.count('tenant_id IN (123,456)'), 14) + self.assertNotIn('deleted=0', sql) + self.assertIn('CAST(`version` AS CHAR)', sql) + for ids in [['123', '123'], ['123 OR 1=1', '456'], ['123']]: + with self.assertRaises(AssertionError): + probe.snapshot_sql(columns, ids) + columns['ord_order'] = ['id', 'unsafe`field'] + with self.assertRaises(AssertionError): + probe.snapshot_sql(columns, ['123', '456']) + def response(self): return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e', 'result': {'total': 1, 'succeeded': 0, 'failed': 1, 'items': [ diff --git a/scripts/client-xlsx-address-test-env.mjs b/scripts/client-xlsx-address-test-env.mjs index f7e1012dd..b218d1fbe 100644 --- a/scripts/client-xlsx-address-test-env.mjs +++ b/scripts/client-xlsx-address-test-env.mjs @@ -9,7 +9,7 @@ import { customerProfilePath } from './client-import-template-test-env.mjs' export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, writes: 'Two single-row XLSX PARTIAL imports must reject foreign customer addresses; source/error files may be retained; no retry or deletion' } - if (args.length !== 5 || !args[4].startsWith('--python=')) throw Error('Explicit TEST, customer and bundled Python required') + if (![5, 6].includes(args.length) || !args[4].startsWith('--python=')) throw Error('Explicit TEST, customer and bundled Python required') const options = executionOptions(args.slice(0, 3), execArgs, env) const credentials = parseEnv(await readFile(customerProfilePath(args[3], execArgs[0].slice(11)), 'utf8')) if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw Error('Missing control credentials') @@ -19,12 +19,26 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e const config = { environment: 'TEST', base: options.base, runId: options.runId, source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }, control: { loginId: credentials.MERCHANT_TEST_USERNAME, password: credentials.MERCHANT_TEST_PASSWORD } } + if (args[5]) { + const profile = databaseProfilePath(args[5], execArgs[0].slice(11)) + const database = parseEnv(await readFile(profile, 'utf8')) + if (database.CHONGCHONG_ENV?.toUpperCase() !== 'TEST') throw Error('TEST database required') + for (const key of ['HOST', 'PORT', 'USERNAME', 'PASSWORD']) + if (!database['CHONGCHONG_DB_' + key]) throw Error('Incomplete TEST database configuration') + config.database = Object.fromEntries(['HOST', 'PORT', 'USERNAME', 'PASSWORD'].map(key => [key.toLowerCase(), database['CHONGCHONG_DB_' + key]])) + } const child = spawn(python, ['-B', resolve(root, 'scripts/client-xlsx-address-probe.py')], { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) child.stdin.end(JSON.stringify(config)) const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) return { success: code === 0, report: resolve(root, 'reports', `client-xlsx-address-${options.runId}`, 'report.json') } } +export function databaseProfilePath(argument, environmentFile) { + if (!argument.startsWith('--database-profile=')) throw Error('Unknown argument') + const profile = resolve(argument.slice(19)) + if (profile !== resolve(dirname(environmentFile), 'test-database.env')) throw Error('Use explicit test-database.env beside TEST acceptance profile') + return profile +} if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) execute().then(r => { console.log(JSON.stringify(r)); if (r.success === false) process.exitCode = 1 }) .catch(() => { console.error('XLSX probe did not start; check explicit TEST inputs and unused run ID.'); process.exitCode = 1 }) diff --git a/scripts/client-xlsx-address-test-env.test.mjs b/scripts/client-xlsx-address-test-env.test.mjs index e99e33d2e..6daa22d9e 100644 --- a/scripts/client-xlsx-address-test-env.test.mjs +++ b/scripts/client-xlsx-address-test-env.test.mjs @@ -1,7 +1,13 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { execute } from './client-xlsx-address-test-env.mjs' +import { execute, databaseProfilePath } from './client-xlsx-address-test-env.mjs' test('XLSX rejection plan performs no network or credential reads', async () => { assert.equal((await execute(['--plan'], [], {})).plan, true) await assert.rejects(execute(['--execute'], [], {})) }) +test('database evidence accepts only the named TEST sibling profile', () => { + const base = 'C:/Users/test/.codex/env/chongchong-global/test-acceptance.env' + assert.match(databaseProfilePath('--database-profile=C:/Users/test/.codex/env/chongchong-global/test-database.env', base), /test-database.env$/) + for (const filename of ['prod-database.env', 'dev-database.env', '../other/test-database.env']) + assert.throws(() => databaseProfilePath('--database-profile=C:/Users/test/.codex/env/chongchong-global/' + filename, base)) +}) -- 2.54.0 From 4ff55ba3e08c7fe0c6e04832fa95ebb680c76b8c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 17:52:47 +0800 Subject: [PATCH 109/176] fix(ci): bound Maven resources and serialize shared builder jobs --- Jenkinsfile | 14 +++-- Jenkinsfile.service | 7 ++- deploy/scripts/run-ci-maven.sh | 37 ++++++++++++ pom.xml | 24 ++++++++ scripts/README.md | 2 + scripts/ci-maven-resource-contract.test.mjs | 67 +++++++++++++++++++++ 6 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 deploy/scripts/run-ci-maven.sh create mode 100644 scripts/ci-maven-resource-contract.test.mjs diff --git a/Jenkinsfile b/Jenkinsfile index 047ec59ad..3659d5dfb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -327,11 +327,15 @@ pipeline { .findAll { it } .join(',') echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" - sh """ - test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" - find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete - mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test - """ + lock(resource: 'firstleg-test-maven') { + timeout(time: 50, unit: 'MINUTES') { + sh """ + test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" + find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete + sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + } + } echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" } } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 1b34f46dc..a11c90f78 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -55,7 +55,6 @@ pipeline { environment { REGISTRY_CREDENTIALS = 'gitea-registry-token' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' - MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' } stages { @@ -141,7 +140,11 @@ pipeline { if (params.SKIP_TESTS && coordinatorPinnedCommit) { echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" } - sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + lock(resource: 'firstleg-test-maven') { + timeout(time: 50, unit: 'MINUTES') { + sh "sh deploy/scripts/run-ci-maven.sh --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + } + } } } post { diff --git a/deploy/scripts/run-ci-maven.sh b/deploy/scripts/run-ci-maven.sh new file mode 100644 index 000000000..c300267e2 --- /dev/null +++ b/deploy/scripts/run-ci-maven.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Linux Jenkins entry; no service deployment, data cleanup or dependency installation. +set -eu + +for tool in flock timeout mvn; do + command -v "$tool" >/dev/null 2>&1 || { + echo "Required CI tool missing: $tool" >&2 + exit 69 + } +done +for argument in "$@"; do + case "$argument" in + -T*|--threads*) + echo 'Parallel Maven reactors are disabled on the shared TEST builder.' >&2 + exit 64 + ;; + esac +done + +# All jobs run as the same builder account. Keep the inode: flock releases on close. +# A controller-side abort must not permit another build over a surviving local build. +umask 077 +lock_dir="${HOME:?HOME is required}/.cache/chongchong-ci" +mkdir -p "$lock_dir" +exec 9>"$lock_dir/maven.lock" +if ! flock -n 9; then + echo 'Another local Maven build still holds the CI lock; inspect it before retrying.' >&2 + exit 75 +fi + +# Separate budgets: this process is Maven; ci-bounded configures its test JVM. +MAVEN_OPTS='-Xms128m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=128m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' +export MAVEN_OPTS + +# Local watchdog still runs if the Jenkins controller/agent connection is lost. +# Do not use --foreground: timeout must signal the forked process group too. +exec timeout --signal=TERM --kill-after=30s 45m mvn -Pci-bounded "$@" diff --git a/pom.xml b/pom.xml index 6755a57e4..d2431172e 100644 --- a/pom.xml +++ b/pom.xml @@ -171,6 +171,30 @@ + + ci-bounded + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -Xms128m -Xmx1024m -XX:MaxMetaspaceSize=384m -XX:MaxDirectMemorySize=128m -XX:+ExitOnOutOfMemoryError + 1 + true + ping + kill + 1200 + 30 + + + + + local diff --git a/scripts/README.md b/scripts/README.md index a8859e8dd..bad458f43 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`deploy/scripts/run-ci-maven.sh` 是 Linux Jenkins 的受限 Maven 构建入口,不部署、不访问业务数据库、不安装依赖。总控及单服务 Job 共用 `firstleg-test-maven` 锁;同一构建账号另用 `$HOME/.cache/chongchong-ci/maven.lock` 文件锁,已有本机持锁任务时失败退出,不删除锁文件或杀其他进程。入口禁止 `-T/--threads`,固定 Maven 堆768MiB、元空间256MiB、直接内存128MiB,启用根POM `ci-bounded`:单个测试JVM、堆1GiB、元空间384MiB、直接内存128MiB,保留JaCoCo迟绑定;OOM快速退出,父进程丢失检测、停止时kill、单测试JVM20分钟上限。独立于Jenkins连接的本机GNU timeout在45分钟后TERM、30秒后KILL进程组,流水线另设50分钟超时;缺少flock/timeout/mvn直接失败。构建输出及Surefire报告沿现有Jenkins归档;失败先确认原进程退出再重跑,不自动清理缓存。该预算不等于总RSS硬限额,代理本身和JVM原生内存仍需保留余量;节点执行器应设为1、保持systemd control-group清理,并在真实Linux节点验证峰值。`ci-maven-resource-contract.test.mjs` 检查双入口接线、参数拒绝/失败传播和子JVM配置,Shell替身检查不代替Linux实际锁与信号验收。 + `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 `client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表浏览器通过。可追加 `--database-profile=<同目录test-database.env>`:用现有 mysql CLI 的只读一致性事务,在导入前及每次拒绝后读取两客户 Order 的订单、货物、参与方、内部备注、状态历史、幂等和领域 Outbox 七表完整记录摘要(含逻辑删除),严格比较数量与完整摘要;不同、缺表、截断或查询失败即停止。DB 凭据只进入子进程环境,报告不含原始行;文件、审计及认证正常留存不属于这七表不变断言,不声称全环境零写入。未传此参数只验 HTTP。对应 Node/Python 测试检查零网络计划、环境边界、精准拒绝、模板其他部分不变及数据库快照完整性。 diff --git a/scripts/ci-maven-resource-contract.test.mjs b/scripts/ci-maven-resource-contract.test.mjs new file mode 100644 index 000000000..321f7321f --- /dev/null +++ b/scripts/ci-maven-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const wrapper = resolve(root, 'deploy/scripts/run-ci-maven.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +test('both Jenkins Maven paths share one cross-job lock and bounded host entry', () => { + for (const file of ['Jenkinsfile', 'Jenkinsfile.service']) { + const source = readFileSync(resolve(root, file), 'utf8') + assert.match(source, /lock\(resource: 'firstleg-test-maven'\)\s*\{\s*timeout\(time: 50, unit: 'MINUTES'\)/) + assert.match(source, /sh deploy\/scripts\/run-ci-maven\.sh --settings/) + assert.doesNotMatch(source, /(?:^|\n)\s*mvn\s|sh "mvn /) + } + const source = readFileSync(wrapper, 'utf8') + assert.match(source, /exec 9>/) + assert.match(source, /flock -n 9/) + assert.match(source, /exec timeout --signal=TERM --kill-after=30s 45m mvn -Pci-bounded/) + assert.doesNotMatch(source, /exec timeout[^\n]*--foreground/) +}) + +test('test JVM gets a separate heap, JaCoCo late binding, and parent-loss cleanup', () => { + const pom = readFileSync(resolve(root, 'pom.xml'), 'utf8') + const profile = pom.match(/\s*ci-bounded<\/id>([\s\S]*?)<\/profile>/)?.[1] + assert.ok(profile) + assert.match(profile, /@\{argLine\}[^<]*-Xmx1024m[^<]*-XX:\+ExitOnOutOfMemoryError<\/argLine>/) + for (const [name, value] of Object.entries({ forkCount: '1', enableProcessChecker: 'ping', shutdown: 'kill', + forkedProcessTimeoutInSeconds: '1200', forkedProcessExitTimeoutInSeconds: '30' })) + assert.ok(profile.includes(`<${name}>${value}`)) +}) + +test('shell rejects parallel reactors and held host lock before invoking Maven; propagates failure', { skip: !shellAvailable }, () => { + const directory = mkdtempSync(join(tmpdir(), 'ci-maven-command-test-')) + try { + // Stand-ins exercise command wiring, not Linux flock or process-group signalling. + writeFileSync(join(directory, 'flock'), '#!/bin/sh\nexit "${TEST_LOCK_STATUS:-0}"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'mvn'), '#!/bin/sh\nexit 99\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" "$MAVEN_OPTS" "$@"\nexit "${TEST_CHILD_STATUS:-0}"\n', { mode: 0o755 }) + const run = (args, extra = {}) => spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), ...args], + { encoding: 'utf8', timeout: 10000, env: { ...process.env, ...extra } }) + for (const args of [['-T', '3', 'test'], ['-T3', 'test'], ['--threads=3', 'test']]) { + const result = run(args) + assert.equal(result.status, 64, result.stderr) + assert.equal(result.stdout, '') + } + const held = run(['test'], { TEST_LOCK_STATUS: '1' }) + assert.equal(held.status, 75, held.stderr) + assert.equal(held.stdout, '') + const normal = run(['-pl', 'file', '-am', 'test'], { TEST_CHILD_STATUS: '23', MAVEN_OPTS: '-Xmx8g' }) + assert.equal(normal.status, 23, normal.stderr) + assert.match(normal.stdout, /-Xmx768m/) + assert.doesNotMatch(normal.stdout, /-Xmx8g/) + assert.match(normal.stdout, /--signal=TERM\r?\n--kill-after=30s\r?\n45m\r?\nmvn\r?\n-Pci-bounded\r?\n-pl\r?\nfile/) + } finally { + // Only this newly-created fixture directory; no project copies or shared caches removed. + assert.equal(resolve(directory).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/')), true) + rmSync(directory, { recursive: true }) + } +}) -- 2.54.0 From 499354ffd2f58f63926e4db7811081a8ce7b923d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 18:43:25 +0800 Subject: [PATCH 110/176] test(firstleg): align initialization table count with current baseline --- .../repository/Phase3FinalUnifiedSqlContractTest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 9d0fa239d..0b9bc9808 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -80,7 +80,12 @@ class Phase3FinalUnifiedSqlContractTest { @Test void initializationPassIsUniqueAndFinal() throws Exception { String sql = sql(); - String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables;"; + int expectedTables = 226; + long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+") + .matcher(sql).results().count(); + assertThat(actualTables).isEqualTo(expectedTables); + String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, " + + expectedTables + " AS initialized_tables;"; assertThat(count(sql, pass)).isEqualTo(1); assertThat(sql.trim()).endsWith(pass); assertThat(sql) -- 2.54.0 From 3bf261247065dbd294e8f3557cbdddc25b509ea3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 19:07:58 +0800 Subject: [PATCH 111/176] test(fleet): journal prerequisite writes and stop on unknown results --- scripts/fleet-test-env-acceptance.mjs | 65 ++++++++++++++++ scripts/fleet-test-env-acceptance.test.mjs | 87 +++++++++++++++++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 942946248..0bf83fb87 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -23,6 +23,63 @@ const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && id(response?.body?.code) === '200' const key = () => `fleet-${randomUUID()}` +/** Install before login/prerequisites. A caught unknown write must not permit another request. */ +export function guardFleetWrites(context, checkpoint) { + let stopped = null, sequence = 0 + for (const name of ['request', 'requestWithBody', 'requestBinary']) { + const original = context[name] + if (typeof original !== 'function') continue + context[name] = async function (method, path, body, token, headers) { + if (stopped) throw stopped + if (['GET', 'HEAD', 'OPTIONS'].includes(String(method).toUpperCase())) + return original.apply(this, arguments) + const entry = { sequence: ++sequence, method, path, status: 'SENDING', startedAt: new Date().toISOString(), + headers, body } + // Multipart delegates to requestWithBody, so it is journaled exactly once, after construction. + if (body instanceof FormData) { + entry.body = { multipart: [] } + for (const [field, value] of body.entries()) entry.body.multipart.push(typeof value === 'string' + ? { field, value } : { field, name: value.name, type: value.type, + base64: Buffer.from(await value.arrayBuffer()).toString('base64') }) + } + try { await checkpoint(entry) } catch (error) { stopped = error; throw error } + try { + const response = await original.apply(this, arguments) + const http = response?.http, payload = response?.body, code = id(payload?.code) + const definite = Number.isInteger(http) && http >= 200 && http < 500 + && payload && typeof payload === 'object' && !Array.isArray(payload) && response.requestId && response.traceId + && (http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400)) + Object.assign(entry, { status: definite ? 'RESPONDED' : 'UNKNOWN_RESULT', http, code, + requestId: response?.requestId, traceId: response?.traceId, finishedAt: new Date().toISOString() }) + if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`) + await checkpoint(entry) + if (stopped) throw stopped + return response + } catch (error) { + stopped ||= error + if (entry.status === 'SENDING') { + entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString() + await checkpoint(entry) + } + throw stopped + } + } + } +} + +export function redactFleetWrite(value, secrets = []) { + const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|token$|^(?:loginId|username)$/i.test(name) + if (Array.isArray(value)) return value.map(item => redactFleetWrite(item, secrets)) + if (value && typeof value === 'object' && typeof value.field === 'string' && sensitive(value.field)) + return { field: value.field, value: '[REDACTED]' } + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, + sensitive(name) + ? '[REDACTED]' : redactFleetWrite(item, secrets)])) + if (typeof value === 'string') return secrets.filter(secret => typeof secret === 'string' && secret.length) + .reduce((text, secret) => text.split(secret).join('[REDACTED]'), value) + return value +} + export function parseFleetArguments(args) { const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'handover' } let action = null @@ -498,6 +555,14 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c pdaUsername: env.ADMIN_PDA_USERNAME, pdaPassword: env.ADMIN_PDA_PASSWORD, merchantUsername: '', merchantPassword: '', markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) context = createContext ? createContext(options) : new AcceptanceRun(options) + let writeCheckpoint = 0 + guardFleetWrites(context, entry => { + const payload = JSON.stringify(redactFleetWrite({ marker: context.marker, runId: config.runId, request: entry }, + [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n' + const descriptor = openSync(join(output, 'evidence', `write-${String(++writeCheckpoint).padStart(6, '0')}.json`), 'wx') + try { writeFileSync(descriptor, payload); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + }) context.context.fleetRunId = config.runId context.context.fleetScenario = config.scenario || 'handover' context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 7cf1e9561..6ed19e498 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -3,7 +3,66 @@ import assert from 'node:assert/strict' import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, dirname, resolve } from 'node:path' -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors } from './fleet-test-env-acceptance.mjs' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs' + +for (const response of [ + { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, + { http: 200, body: null }, { http: 200, body: { code: '200' } }, + { http: 302, body: { code: '200' }, requestId: 'r', traceId: 't' }, +]) test(`unknown prerequisite write stops every subsequent request: ${JSON.stringify(response)}`, async () => { + const saved = [], calls = [] + const ctx = { async request(method, path, body) { + assert.equal(saved.at(-1).status, 'SENDING') + assert.deepEqual(saved.at(-1).body, body) + calls.push(path); return response + } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + const body = { expectedVersion: 6, idempotencyKey: 'original-key', internalNotes: ['test remark'] } + await assert.rejects(ctx.request('PUT', '/orders/1/remarks?tenantId=2', body), /Unknown write outcome/) + body.expectedVersion = 99 + assert.equal(saved[0].body.expectedVersion, 6) + assert.equal(saved[1].status, 'UNKNOWN_RESULT') + for (const method of ['POST', 'GET', 'DELETE']) await assert.rejects(ctx.request(method, '/must-not-run'), /Unknown write outcome/) + assert.deepEqual(calls, ['/orders/1/remarks?tenantId=2']) +}) + +test('formal correlated rejection is recorded and remains available for expected negative assertions', async () => { + const saved = [], response = { http: 200, body: { code: 'A0409' }, requestId: 'r', traceId: 't' } + const ctx = { async request() { return response } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + assert.equal(await ctx.request('POST', '/negative', { recordVersion: 7 }), response) + assert.equal(saved[1].status, 'RESPONDED') + await ctx.request('GET', '/readback'); assert.equal(saved.length, 2) +}) + +test('checkpoint failure stops before dispatch; transport throw stops even after caller catches', async () => { + let calls = 0 + const ctx = { async request() { calls++; throw Error('transport failure') } } + guardFleetWrites(ctx, () => { throw Error('disk full') }) + await assert.rejects(ctx.request('POST', '/write'), /disk full/) + await assert.rejects(ctx.request('GET', '/later'), /disk full/); assert.equal(calls, 0) + const saved = [], other = { async request() { calls++; throw Error('transport failure') } } + guardFleetWrites(other, entry => saved.push(structuredClone(entry))) + await assert.rejects(other.request('PUT', '/write'), /transport failure/) + await assert.rejects(other.request('POST', '/cleanup'), /transport failure/) + assert.equal(calls, 1); assert.equal(saved[1].status, 'UNKNOWN_RESULT') +}) + +test('multipart delegates once and retains recoverable fixture bytes, business keys and versions without credentials', async () => { + const saved = [], ctx = { + async requestMultipart(method, path, form) { return this.requestWithBody(method, path, form) }, + async requestWithBody() { return { http: 200, body: { code: '200' }, requestId: 'r', traceId: 't' } }, + } + guardFleetWrites(ctx, entry => saved.push(redactFleetWrite(structuredClone(entry)))) + const form = new FormData(); form.append('file', new Blob(['fixture']), 'proof.txt'); form.append('password', 'hidden') + await ctx.requestMultipart('POST', '/upload', form) + assert.equal(saved.length, 2); assert.equal(Buffer.from(saved[0].body.multipart[0].base64, 'base64').toString(), 'fixture') + assert.ok(!JSON.stringify(saved).includes('hidden')) + const clean = redactFleetWrite({ draftToken: 'business-draft', idempotencyKey: 'command-key', expectedVersion: 6, + headers: { Authorization: 'Bearer hidden', 'X-Access-Token': 'hidden' }, body: { password: 'hidden', accessToken: 'hidden' } }) + assert.equal(clean.draftToken, 'business-draft'); assert.equal(clean.idempotencyKey, 'command-key') + assert.equal(clean.expectedVersion, 6); assert.ok(!JSON.stringify(clean).includes('hidden')) +}) test('fleet entry defaults to a no-side-effect plan and requires explicit TEST and unique run ID', () => { const p = parseFleetArguments([]) @@ -118,12 +177,24 @@ async function executeFixture(failProducer = false) { CHONGCHONG_ADMIN_PASSWORD: secrets, ADMIN_PDA_USERNAME: 'fixture-pda', ADMIN_PDA_PASSWORD: secrets } const config = parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'fixture']) const createContext = options => ({ options, context: {}, createdResources: {}, steps: [], modules: [], coverageGaps: [], token: secrets, + async request(method, path, body) { + const pending = JSON.parse(readFileSync(join(root, 'reports/fleet-fixture/evidence/write-000001.json'), 'utf8')) + assert.equal(pending.request.status, 'SENDING'); assert.deepEqual(pending.request.body, body) + assert.equal(pending.request.path, path); calls.push('unknown-write'); return { http: 0 } + }, async executeModule(name, handler) { try { await handler(); this.modules.push({ name, success: true }) } catch { this.modules.push({ name, success: false }); this.steps.push({ name: 'producer failed', passed: false }) } }, async run() { - await this.executeModule('producer', async () => { calls.push('producer'); if (failProducer) throw Error('fixture failure') }) + await this.executeModule('producer', async () => { + calls.push('producer') + if (failProducer === 'unknown') { + try { await this.request('PUT', '/orders/1/remarks?tenantId=2', { expectedVersion: 6, idempotencyKey: 'original-key', internalNotes: ['fixture'] }) } catch { /* Simulate a prerequisite swallowing an error. */ } + await this.request('POST', '/loading/must-not-run', {}) + } + if (failProducer) throw Error('fixture failure') + }) await this.executeModule('phase4-overseas-dispatch', async () => { calls.push('dispatch'); this.createdResources.current = { accessToken: secrets } }) return { success: true, steps: this.steps, modules: this.modules, createdResources: this.createdResources, summary: { failed: 0 } } }, @@ -145,6 +216,11 @@ async function executeFixture(failProducer = false) { } } const result = await runFleetAcceptance(config, options) const report = JSON.parse(readFileSync(result.report, 'utf8')) + if (failProducer === 'unknown') { + const unknown = JSON.parse(readFileSync(join(root, 'reports/fleet-fixture/evidence/write-000002.json'), 'utf8')) + assert.equal(unknown.request.status, 'UNKNOWN_RESULT'); assert.equal(unknown.request.body.expectedVersion, 6) + assert.equal(unknown.request.body.idempotencyKey, 'original-key') + } assert.ok(!readFileSync(result.report, 'utf8').includes(secrets)) assert.equal(existsSync(join(root, 'reports/.fleet-acceptance.lock')), false) const oldReport = readFileSync(result.report, 'utf8') @@ -174,6 +250,13 @@ test('failed prerequisite stops before further fixture creation and retains the assert.equal(report.fatal, 'Producer stopped: producer') }) +test('real fleet entry journals prerequisite writes before sending and stops loading even if producer catches unknown outcome', async () => { + const { result, report, calls } = await executeFixture('unknown') + assert.equal(result.success, false) + assert.deepEqual(calls, ['preflight', 'producer', 'unknown-write']) + assert.equal(report.fatal, 'Producer stopped: producer') +}) + test('environment mismatch rejects before invoking any readers or contexts', async () => { let touched = false await assert.rejects(() => runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'new' }, { -- 2.54.0 From c5163475651a68d6b80d7298b4f29f9d38f5ff14 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 19:09:22 +0800 Subject: [PATCH 112/176] test(fleet): verify booking quote source linkage in TEST --- scripts/README.md | 2 + ...-booking-quotes-test-env-contract.test.mjs | 51 ++++++++ scripts/fleet-booking-quotes-test-env.mjs | 117 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 scripts/fleet-booking-quotes-test-env-contract.test.mjs create mode 100644 scripts/fleet-booking-quotes-test-env.mjs diff --git a/scripts/README.md b/scripts/README.md index bad458f43..0fdaff748 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 + `deploy/scripts/run-ci-maven.sh` 是 Linux Jenkins 的受限 Maven 构建入口,不部署、不访问业务数据库、不安装依赖。总控及单服务 Job 共用 `firstleg-test-maven` 锁;同一构建账号另用 `$HOME/.cache/chongchong-ci/maven.lock` 文件锁,已有本机持锁任务时失败退出,不删除锁文件或杀其他进程。入口禁止 `-T/--threads`,固定 Maven 堆768MiB、元空间256MiB、直接内存128MiB,启用根POM `ci-bounded`:单个测试JVM、堆1GiB、元空间384MiB、直接内存128MiB,保留JaCoCo迟绑定;OOM快速退出,父进程丢失检测、停止时kill、单测试JVM20分钟上限。独立于Jenkins连接的本机GNU timeout在45分钟后TERM、30秒后KILL进程组,流水线另设50分钟超时;缺少flock/timeout/mvn直接失败。构建输出及Surefire报告沿现有Jenkins归档;失败先确认原进程退出再重跑,不自动清理缓存。该预算不等于总RSS硬限额,代理本身和JVM原生内存仍需保留余量;节点执行器应设为1、保持systemd control-group清理,并在真实Linux节点验证峰值。`ci-maven-resource-contract.test.mjs` 检查双入口接线、参数拒绝/失败传播和子JVM配置,Shell替身检查不代替Linux实际锁与信号验收。 `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 diff --git a/scripts/fleet-booking-quotes-test-env-contract.test.mjs b/scripts/fleet-booking-quotes-test-env-contract.test.mjs new file mode 100644 index 000000000..d145b5071 --- /dev/null +++ b/scripts/fleet-booking-quotes-test-env-contract.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { warehouseQuoteContext, verifyBookingQuotes } from './fleet-booking-quotes-test-env.mjs' + +test('warehouse date uses authoritative timezone and rejects ambiguous or customer-owned warehouse', () => { + const detail = { warehouse: { id: '92', tenantId: '0', status: 'ACTIVE', warehouseType: 'OVERSEAS', timezone: 'America/Los_Angeles' }, addresses: [{ warehouseId: '92', tenantId: '0', city: 'LA' }] } + assert.equal(warehouseQuoteContext(detail, '2026-10-04T01:00:00Z').day, '2026-10-03') + assert.equal(warehouseQuoteContext({ ...detail, warehouse: { ...detail.warehouse, timezone: 'UTC-8' } }, '2026-10-04T01:00:00Z').day, '2026-10-03') + assert.throws(() => warehouseQuoteContext({ ...detail, warehouse: { ...detail.warehouse, tenantId: '1' } }, '2026-10-04T01:00:00Z')) + assert.throws(() => warehouseQuoteContext({ ...detail, addresses: [...detail.addresses, { ...detail.addresses[0], city: 'SF' }] }, '2026-10-04T01:00:00Z')) +}) +function harness(fault) { + const quotes = [], bookings = [], replay = new Map(), calls = [] + let trip = { tripNo: 'TR-OWN', recordVersion: 0 }, expired = false + const context = { marker: 'TEST-CURRENT', createdResources: { merchantQualificationFixture: { marker: 'TEST-CURRENT', tenantId: '9' } }, async request(method, path, body) { + calls.push({ method, path, body: structuredClone(body) }); const url = new URL(path, 'http://offline.invalid'), p = url.pathname + const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }) + if (p.endsWith('/quote-candidates')) { + const basis = url.searchParams.get('priceBasis'), currency = url.searchParams.get('currency') + return response({ records: quotes.filter(q => !expired && q.details.validFrom === '2026-10-04' && q.details.currency === currency && q.details[basis === 'PALLET' ? 'looseDeliveryPrice' : 'fullDeliveryPrice'] != null).map(q => ({ quoteNo: q.quoteNo, recordVersion: q.recordVersion, quotedUnitPrice: q.details[basis === 'PALLET' ? 'looseDeliveryPrice' : 'fullDeliveryPrice'], priceItem: basis === 'PALLET' ? 'LOOSE_DELIVERY' : 'FULL_DELIVERY', effectiveOn: '2026-10-04' })) }) + } + if (p.endsWith('/quotes') && method === 'POST') { const q = { quoteNo: `FQ-${quotes.length}`, recordVersion: 0, details: body.details }; quotes.push(q); return response(q) } + if (p.includes('/quotes/FQ-')) { const q = quotes.find(q => p.endsWith(q.quoteNo)); q.details = body.details; q.recordVersion++; return response(q) } + if (p.endsWith('/trips') && method === 'POST') return response({ trip }) + if (p.endsWith('/plan')) { expired = true; trip.recordVersion++; return response({ trip }) } + if (p.endsWith('/confirm')) return response(null, fault === 'accepts-stale' ? '200' : 'A0409') + if (p.endsWith('/bookings') && method === 'GET') return response({ tripRecordVersion: trip.recordVersion, records: bookings }) + if (p.endsWith('/bookings') && replay.has(body.idempotencyKey)) return response({ ...replay.get(body.idempotencyKey), replayed: true }) + const q = body.quoteRef ? quotes.find(q => q.quoteNo === body.quoteRef.quoteNo) : null + if (q && q.recordVersion !== body.quoteRef.recordVersion) return response(null, 'A0409') + let b + if (method === 'POST') { b = { bookingNo: `FB-${bookings.length}`, status: 'DRAFT', recordVersion: 0 }; bookings.push(b) } + else { b = bookings.find(b => p.endsWith(b.bookingNo)); b.recordVersion++ } + Object.assign(b, { cargo: body.cargo, agreedAmount: body.cargo.unitPrice * body.cargo.palletSpaces, quoteSource: q ? { quoteNo: q.quoteNo, recordVersion: q.recordVersion, quotedUnitPrice: q.details.looseDeliveryPrice } : null }) + trip.recordVersion++ + const result = { tripRecordVersion: trip.recordVersion, booking: b, replayed: false } + if (method === 'POST') replay.set(body.idempotencyKey, structuredClone(result)) + return response(result) + } } + return { context, calls } +} +test('independent draft fixture covers source version, date, currency, manual pricing without inventory or departure writes', async () => { + const h = harness(), proof = await verifyBookingQuotes(h.context, { warehouseId: '92', originCity: 'la', day: '2026-10-04' }, '2026-10-04T12:00:00Z') + assert.equal(proof.status, 'VERIFIED_DRAFT_SOURCE_LINKAGE'); assert.ok(proof.checks.every(c => c.passed)) + assert.ok(h.calls.every(c => !/inventory|\/depart$|\/assignment$|\/receipt-confirmation$/.test(c.path))) + await assert.rejects(verifyBookingQuotes(h.context, {}, ''), /no restart/) +}) +test('unexpected acceptance of stale confirmation cannot be reported as successful', async () => { + const h = harness('accepts-stale') + await assert.rejects(verifyBookingQuotes(h.context, { warehouseId: '92', originCity: 'la', day: '2026-10-04' }, '2026-10-04T12:00:00Z'), /A0409/) +}) diff --git a/scripts/fleet-booking-quotes-test-env.mjs b/scripts/fleet-booking-quotes-test-env.mjs new file mode 100644 index 000000000..4ab271d83 --- /dev/null +++ b/scripts/fleet-booking-quotes-test-env.mjs @@ -0,0 +1,117 @@ +import { readFile, mkdir, open } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { requireTestProfile, installQuoteJournal } from './fleet-quotes-test-env.mjs' +import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const MARKER = 'FLEET-BOOKING-QUOTE-20261003-R2' +const TRIPS = '/api/platform/v1/last-leg/fleet/trips', QUOTES = '/api/platform/v1/last-leg/fleet/quotes' +export function warehouseQuoteContext(detail, planned) { + const w = detail?.warehouse + if (!w?.id || String(w.tenantId) !== '0' || w.status !== 'ACTIVE' || !['OVERSEAS', 'OVERSEAS_PARTNER'].includes(w.warehouseType) || !w.timezone) throw Error('Warehouse quote context unavailable') + const cities = [...new Set((detail.addresses || []).filter(a => String(a.warehouseId) === String(w.id) && String(a.tenantId) === '0' && a.city?.trim()).map(a => a.city.trim().toLowerCase()))] + if (cities.length !== 1) throw Error('Warehouse quote city ambiguous') + const offset = /^(?:UTC)?([+-])(\d{1,2})(?::(\d{2}))?$/.exec(w.timezone) + if (offset && (Number(offset[2]) > 18 || Number(offset[3] || 0) > 59 || Number(offset[2]) === 18 && Number(offset[3] || 0) !== 0)) throw Error('Warehouse offset invalid') + const day = offset ? new Date(Date.parse(planned) + (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3] || 0)) * 60000).toISOString().slice(0, 10) + : new Intl.DateTimeFormat('en-CA', { timeZone: w.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(planned)) + return { warehouseId: String(w.id), originCity: cities[0], timezone: w.timezone, day } +} +export async function verifyBookingQuotes(context, warehouse, planned) { + if (context.createdResources.bookingQuoteAcceptance || context.createdResources.merchantQualificationFixture?.marker !== context.marker) throw Error('Fresh own fixture required; no restart') + const proof = context.createdResources.bookingQuoteAcceptance = { marker: context.marker, status: 'CHECKING', warehouse, quotes: [], bookings: [], checks: [], confirmationSuccess: 'NOT_TESTED_REQUIRES_LOAD_PLAN', receiptAndDeparture: 'NOT_TESTED' } + const check = (name, pass) => { proof.checks.push({ name, passed: Boolean(pass) }); if (!pass) throw Error(name) } + const request = async (method, path, body, expected = '200') => { + const r = await context.request(method, path, body) + check(`${method} ${path.split('?')[0]} => ${expected}`, r.http === 200 && String(r.body?.code) === expected && r.requestId && r.traceId) + return r.body.data + } + const customerId = String(context.createdResources.merchantQualificationFixture.tenantId) + const destination = 'QA-BOOKING', stops = [{ name: '本轮报价测试起运仓' }, { name: destination, destinationKey: `FBA:${destination}` }] + let trip = (await request('POST', TRIPS, { warehouseId: warehouse.warehouseId, plannedDepartureAt: planned, expectedDriverEndAt: new Date(Date.parse(planned) + 3600000).toISOString(), stops, remark: context.marker, idempotencyKey: `${context.marker}-trip` })).trip + proof.tripNo = trip.tripNo + const path = `${TRIPS}/${trip.tripNo}/bookings` + const detail = { kind: 'SALE', partyId: customerId, originCity: warehouse.originCity, destinationCode: destination, distanceMiles: 10, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: 25, looseDeliveryPrice: 10, validFrom: warehouse.day, validTo: warehouse.day, remark: context.marker } + const createQuote = async (suffix, change) => { const quote = await request('POST', QUOTES, { idempotencyKey: `${context.marker}-${suffix}`, details: { ...detail, ...change } }); proof.quotes.push(quote.quoteNo); return quote } + const valid = await createQuote('valid', {}) + const expiredDay = new Date(Date.parse(`${warehouse.day}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) + await createQuote('expired', { distanceMiles: 11, validFrom: expiredDay, validTo: expiredDay }) + const cny = await createQuote('cny', { distanceMiles: 12, currency: 'CNY', fullDeliveryPrice: 35, looseDeliveryPrice: null }) + const zero = await createQuote('zero', { distanceMiles: 13, fullDeliveryPrice: null, looseDeliveryPrice: 0 }) + const candidates = (currency, priceBasis) => request('GET', `${path}/quote-candidates?${new URLSearchParams({ customerId, destinationKey: `FBA:${destination}`, currency, priceBasis, page: '1', size: '100' })}`) + const pallet = await candidates('USD', 'PALLET'), order = await candidates('USD', 'ORDER'), cnyPallet = await candidates('CNY', 'PALLET'), cnyOrder = await candidates('CNY', 'ORDER') + check('USD pallet candidate excludes expired/currency mismatch but preserves explicit zero', pallet.records.length === 2 && pallet.records.some(q => q.quoteNo === zero.quoteNo && Number(q.quotedUnitPrice) === 0) && pallet.records.some(q => q.quoteNo === valid.quoteNo)) + check('ORDER selects full-delivery instead of loose-delivery price', order.records.length === 1 && order.records[0].quoteNo === valid.quoteNo && order.records[0].priceItem === 'FULL_DELIVERY' && Number(order.records[0].quotedUnitPrice) === 25) + check('Missing CNY pallet price is not zero', cnyPallet.records.length === 0 && cnyOrder.records.length === 1 && cnyOrder.records[0].quoteNo === cny.quoteNo) + check('Server date equals authoritative warehouse local planned day', pallet.records.every(q => q.effectiveOn === warehouse.day)) + const cargo = { destinationKey: `FBA:${destination}`, cargoName: '报价联动测试', packageCount: 2, palletSpaces: 2, weightKg: 10, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 7, currency: 'USD', contactName: null, contactPhone: null, remark: context.marker } + const reference = { quoteNo: valid.quoteNo, recordVersion: valid.recordVersion, priceItem: 'LOOSE_DELIVERY' } + const firstBody = { tripRecordVersion: trip.recordVersion, customerId, cargo, quoteRef: reference, idempotencyKey: `${context.marker}-booking` } + const first = await request('POST', path, firstBody); trip.recordVersion = first.tripRecordVersion; proof.bookings.push(first.booking.bookingNo) + check('Source price frozen separately from negotiated amount', Number(first.booking.quoteSource.quotedUnitPrice) === 10 && Number(first.booking.cargo.unitPrice) === 7 && Number(first.booking.agreedAmount) === 14 && String(first.booking.quoteSource.recordVersion) === String(valid.recordVersion)) + const manual = await request('POST', path, { tripRecordVersion: trip.recordVersion, customerId, cargo: { ...cargo, unitPrice: 0 }, quoteRef: null, idempotencyKey: `${context.marker}-manual` }); trip.recordVersion = manual.tripRecordVersion; proof.bookings.push(manual.booking.bookingNo) + check('Manual zero-price draft never gains a mandatory source', manual.booking.quoteSource == null && Number(manual.booking.agreedAmount) === 0) + const changed = await request('PUT', `${QUOTES}/${valid.quoteNo}`, { recordVersion: valid.recordVersion, details: { ...detail, looseDeliveryPrice: 12 } }) + const replay = await request('POST', path, firstBody) + check('Exact create replay retains original source snapshot after quote edit', replay.replayed === true && Number(replay.booking.quoteSource.quotedUnitPrice) === 10) + await request('POST', path, { ...firstBody, tripRecordVersion: trip.recordVersion, idempotencyKey: `${context.marker}-stale` }, 'A0409') + await request('POST', `${path}/${first.booking.bookingNo}/confirm`, { tripRecordVersion: trip.recordVersion, recordVersion: first.booking.recordVersion, idempotencyKey: `${context.marker}-stale-confirm` }, 'A0409') + const afterReject = await request('GET', path) + check('Stale source rejection does not mutate booking or parent version', String(afterReject.tripRecordVersion) === String(trip.recordVersion) && afterReject.records.find(b => b.bookingNo === first.booking.bookingNo)?.status === 'DRAFT') + const refreshed = await candidates('USD', 'PALLET'), current = refreshed.records.find(q => q.quoteNo === valid.quoteNo) + check('Explicit refresh exposes new source version and rate', String(current?.recordVersion) === String(changed.recordVersion) && Number(current?.quotedUnitPrice) === 12) + const updated = await request('PUT', `${path}/${first.booking.bookingNo}`, { tripRecordVersion: trip.recordVersion, recordVersion: first.booking.recordVersion, cargo, quoteRef: { ...reference, recordVersion: current.recordVersion } }); trip.recordVersion = updated.tripRecordVersion + check('Explicit reselection refreshes source while preserving negotiated price', Number(updated.booking.quoteSource.quotedUnitPrice) === 12 && Number(updated.booking.agreedAmount) === 14) + const later = new Date(Date.parse(planned) + 2 * 86400000).toISOString() + trip = (await request('PUT', `${TRIPS}/${trip.tripNo}/plan`, { recordVersion: trip.recordVersion, plannedDepartureAt: later, expectedDriverEndAt: new Date(Date.parse(later) + 3600000).toISOString(), stops, remark: context.marker, reason: '仅本轮报价有效日期验证' })).trip + check('Plan date outside validity removes candidates', (await candidates('USD', 'PALLET')).records.length === 0) + await request('POST', `${path}/${first.booking.bookingNo}/confirm`, { tripRecordVersion: trip.recordVersion, recordVersion: updated.booking.recordVersion, idempotencyKey: `${context.marker}-expired-confirm` }, 'A0409') + proof.finalBookings = await request('GET', path); proof.status = 'VERIFIED_DRAFT_SOURCE_LINKAGE'; proof.cleanup = 'RETAINED_OWN_PLANS_QUOTES_AND_DRAFTS_NO_RESOURCE_ASSIGNMENT' + return proof +} +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const envFile = requireTestProfile(args, execArgs), env = parseEnv(await readFile(envFile, 'utf8')) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('TEST credentials missing') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw Error('Invalid TEST API') + // R1 stopped before creating any business fixture because its offset parser rejected valid Java ZoneId UTC-8. + // Preserve that evidence. This corrected run has a distinct claim, marker and request-key namespace. + const directory = resolve(root, 'reports/fleet-booking-quotes-live-20261003-r2') + await mkdir(directory, { recursive: true }) + const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, customerName: `选价测试客户-${MARKER}`, quiet: true }) + context.marker = MARKER + const journal = await installQuoteJournal(context, directory, { secrets: [env.CHONGCHONG_ADMIN_PASSWORD, env.CHONGCHONG_ADMIN_USERNAME], persist: async (name, data) => { + const file = await open(resolve(directory, name), 'wx', 0o600) + try { await file.writeFile(JSON.stringify({ ...data, marker: MARKER, scope: 'BOOKING_QUOTE_ONLY', deployedCommit: '4ff55ba3e08c7fe0c6e04832fa95ebb680c76b8c' }, null, 2)); await file.sync() } finally { await file.close() } + } }) + let error = null, loggedOut = false + try { + const login = await context.request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + if (login.http !== 200 || String(login.body?.code) !== '200' || !login.body.data?.accessToken || !login.body.data?.refreshToken) throw Error('Login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken + const options = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS') + if (String(options.body?.code) !== '200' || !Array.isArray(options.body.data)) throw Error('Warehouse directory failed') + const planned = new Date(Date.now() + 86400000).toISOString(); let warehouse + for (const option of options.body.data.slice(0, 20)) { + const detail = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(option.id)}?tenantId=0`) + if (String(detail.body?.code) !== '200') throw Error('Warehouse details failed') + try { warehouse = warehouseQuoteContext(detail.body.data, planned); break } catch { /* Read next real active warehouse; never write missing master data. */ } + } + if (!warehouse) throw Error('No active warehouse has unambiguous city/timezone; no business fixtures created') + context.createdResources.quoteWarehouse = warehouse + await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false })) + if (context.steps.some(s => !s.passed)) throw Error('Fresh customer fixture failed') + await verifyBookingQuotes(context, warehouse, planned) + } catch (cause) { error = cause.message; if (context.createdResources.bookingQuoteAcceptance) context.createdResources.bookingQuoteAcceptance.status = 'FAILED' } + finally { + try { if (context.token) { const r = await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken }); loggedOut = r.http === 200 && String(r.body?.code) === '200' } } catch { error ||= 'Logout outcome unknown' } + context.token = ''; context.refreshToken = '' + } + const report = { success: !error && loggedOut && !journal.unknown(), error, loggedOut, createdResources: context.createdResources, steps: context.steps, ui: 'NOT_TESTED', operations: 'NOT_TESTED' } + await journal.checkpoint('final', report) + return { success: report.success, directory } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) execute().then(r => { console.log(JSON.stringify(r)); process.exitCode = r.success ? 0 : 1 }).catch(() => { console.error('Booking quote run blocked; preserve checkpoints, no automatic rerun.'); process.exitCode = 1 }) -- 2.54.0 From dbc7688516bd5819e39d8a6e1a73b0b3ebb8c2ee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 19:19:16 +0800 Subject: [PATCH 113/176] test(fleet): preserve PDF and direct fetch prerequisite evidence --- scripts/fleet-test-env-acceptance.mjs | 77 +++++++++++++++---- scripts/fleet-test-env-acceptance.test.mjs | 89 ++++++++++++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 0bf83fb87..30ed00d3a 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -2,6 +2,7 @@ import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { createHash, randomUUID } from 'node:crypto' +import { AsyncLocalStorage } from 'node:async_hooks' import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' @@ -24,17 +25,35 @@ const success = response => response?.http === 200 && id(response?.body?.code) = const key = () => `fleet-${randomUUID()}` /** Install before login/prerequisites. A caught unknown write must not permit another request. */ -export function guardFleetWrites(context, checkpoint) { +export function guardFleetWrites(context, checkpoint, { guardFetch = false } = {}) { let stopped = null, sequence = 0 - for (const name of ['request', 'requestWithBody', 'requestBinary']) { - const original = context[name] + const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch + const direct = { async requestWithBody(method, path, body, token, headers, request) { + const nativeResponse = await originalFetch(request), copy = nativeResponse.clone() + const content = await copy.text() + let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic only. */ } + return { nativeResponse, http: nativeResponse.status, body: payload, raw: payload ? null : content.slice(0, 500), + requestId: payload?.requestId || nativeResponse.headers.get('x-request-id'), + traceId: payload?.traceId || nativeResponse.headers.get('x-trace-id') } + } } + const targets = ['request', 'requestWithBody', 'requestBinary'].map(name => [context, name]) + if (guardFetch) targets.push([direct, 'requestWithBody']) + for (const [owner, name] of targets) { + const original = owner[name] if (typeof original !== 'function') continue - context[name] = async function (method, path, body, token, headers) { + owner[name] = async function (method, path, body, token, headers) { if (stopped) throw stopped if (['GET', 'HEAD', 'OPTIONS'].includes(String(method).toUpperCase())) - return original.apply(this, arguments) + return scope.run(true, () => original.apply(this, arguments)) const entry = { sequence: ++sequence, method, path, status: 'SENDING', startedAt: new Date().toISOString(), headers, body } + let session = owner === direct ? String(headers?.authorization || '').replace(/^Bearer\s+/i, '') + : token === undefined ? this.token : token + if (owner !== direct && this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) session = this.pdaToken + entry.actor = session && session === context.pdaToken ? { kind: 'PDA', source: 'CONTEXT_PDA_SESSION' } + : session && session === context.token && context.options?.username ? { kind: 'PLATFORM', source: 'CONTEXT_ADMIN_SESSION' } + : { kind: 'UNKNOWN', source: session ? 'UNRESOLVED_SESSION' : 'NO_SESSION' } // Multipart delegates to requestWithBody, so it is journaled exactly once, after construction. if (body instanceof FormData) { entry.body = { multipart: [] } @@ -44,31 +63,60 @@ export function guardFleetWrites(context, checkpoint) { } try { await checkpoint(entry) } catch (error) { stopped = error; throw error } try { - const response = await original.apply(this, arguments) + const response = await scope.run(true, () => original.apply(this, arguments)) const http = response?.http, payload = response?.body, code = id(payload?.code) + const binary = name === 'requestBinary' && response?.bytes instanceof Uint8Array + const pdf = binary && http === 200 && /^application\/pdf(?:\s*;|$)/i.test(response.contentType || '') + && response.bytes.length > 4 && Buffer.from(response.bytes.subarray(0, 5)).toString('ascii') === '%PDF-' const definite = Number.isInteger(http) && http >= 200 && http < 500 - && payload && typeof payload === 'object' && !Array.isArray(payload) && response.requestId && response.traceId - && (http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400)) + && response.requestId && response.traceId + && (pdf || payload && typeof payload === 'object' && !Array.isArray(payload) + && (name !== 'requestBinary' && http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400))) Object.assign(entry, { status: definite ? 'RESPONDED' : 'UNKNOWN_RESULT', http, code, - requestId: response?.requestId, traceId: response?.traceId, finishedAt: new Date().toISOString() }) + requestId: response?.requestId, traceId: response?.traceId, raw: boundedFleetDiagnostic(response?.raw), + responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() }) + if (binary) entry.binary = { contentType: response.contentType, bytes: response.bytes.length, + sha256: createHash('sha256').update(response.bytes).digest('hex'), validPdf: Boolean(pdf) } if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`) await checkpoint(entry) if (stopped) throw stopped return response } catch (error) { - stopped ||= error + stopped ||= Error(boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) || 'Fleet write failed') if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString() + entry.raw = boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) await checkpoint(entry) } throw stopped } } } + if (guardFetch) globalThis.fetch = async (input, init) => { + if (scope.getStore()) return originalFetch(input, init) + if (stopped) throw stopped + const request = new Request(input, init), url = new URL(request.url) + if (url.origin !== new URL(context.options.gateway).origin) return originalFetch(input, init) + if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return originalFetch(input, init) + const type = request.headers.get('content-type') || '' + let body + if (request.body) body = type.includes('multipart/form-data') ? await request.clone().formData() + : type.includes('application/json') ? await request.clone().json() : await request.clone().text() + const result = await direct.requestWithBody(request.method, url.pathname + url.search, body, undefined, + Object.fromEntries(request.headers), request) + return result.nativeResponse + } + return () => { if (guardFetch) globalThis.fetch = originalFetch } +} + +function boundedFleetDiagnostic(value) { + return typeof value === 'string' ? value.replace(/Bearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]') + .replace(/((?:password|accessToken|refreshToken|authorization)["']?\s*[=:]\s*["']?)[^\s,;"'<>]+/gi, '$1[REDACTED]') + .replace(/https?:\/\/[^\s"'<>]+/gi, '[REDACTED_URL]').slice(0, 500) : null } export function redactFleetWrite(value, secrets = []) { - const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|token$|^(?:loginId|username)$/i.test(name) + const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|api.?key|secret|token$|^(?:loginId|username)$/i.test(name) if (Array.isArray(value)) return value.map(item => redactFleetWrite(item, secrets)) if (value && typeof value === 'object' && typeof value.field === 'string' && sensitive(value.field)) return { field: value.field, value: '[REDACTED]' } @@ -531,7 +579,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid') mkdirSync(resolve(root, 'reports'), { recursive: true }) const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') - let context, initialized = false, report + let context, initialized = false, report, restoreFetch const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(name)).map(([,value]) => value) const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, [...secrets, context?.token, context?.pdaToken, context?.refreshToken, context?.context?.merchantQualificationFixture?.password]), null, 2) + '\n') @@ -556,13 +604,13 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) context = createContext ? createContext(options) : new AcceptanceRun(options) let writeCheckpoint = 0 - guardFleetWrites(context, entry => { + restoreFetch = guardFleetWrites(context, entry => { const payload = JSON.stringify(redactFleetWrite({ marker: context.marker, runId: config.runId, request: entry }, [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n' const descriptor = openSync(join(output, 'evidence', `write-${String(++writeCheckpoint).padStart(6, '0')}.json`), 'wx') try { writeFileSync(descriptor, payload); fsyncSync(descriptor) } finally { closeSync(descriptor) } - }) + }, { guardFetch: true }) context.context.fleetRunId = config.runId context.context.fleetScenario = config.scenario || 'handover' context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) @@ -616,6 +664,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c summary: context ? buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) : { failed: 1 }, steps: context?.steps || [], modules: context?.modules || [], createdResources: context?.createdResources || {} } } finally { + restoreFetch?.() if (initialized) save('report.json', report || { success: false, fatal: 'RUN_INTERRUPTED' }) closeSync(lock); unlinkSync(lockPath) } diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 6ed19e498..0e28c93ba 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict' import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, dirname, resolve } from 'node:path' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs' import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs' for (const response of [ @@ -35,6 +37,93 @@ test('formal correlated rejection is recorded and remains available for expected await ctx.request('GET', '/readback'); assert.equal(saved.length, 2) }) +test('response evidence preserves business messages and returned resource IDs with explicit session provenance and no tokens', async () => { + const saved = [], ctx = { token: 'admin-session', pdaToken: 'pda-session', options: { username: 'test-admin' }, + async request() { return { http: 200, body: { code: '200', message: 'created', data: { + id: '123', businessNo: 'FIXTURE-123', accessToken: 'new-secret', refreshToken: 'refresh-secret' } }, requestId: 'r', traceId: 't' } } } + guardFleetWrites(ctx, entry => saved.push(redactFleetWrite(structuredClone(entry)))) + await ctx.request('POST', '/create', { expectedVersion: 3 }) + assert.deepEqual(saved[0].actor, { kind: 'PLATFORM', source: 'CONTEXT_ADMIN_SESSION' }) + assert.equal(saved[1].responseBody.message, 'created'); assert.equal(saved[1].responseBody.data.id, '123') + await ctx.request('POST', '/pda', {}, 'pda-session'); assert.equal(saved[2].actor.kind, 'PDA') + await ctx.request('POST', '/other', {}, 'unresolved-session'); assert.equal(saved[4].actor.kind, 'UNKNOWN') + await ctx.request('POST', '/login', {}, ''); assert.equal(saved[6].actor.source, 'NO_SESSION') + for (const secret of ['admin-session', 'pda-session', 'new-secret', 'refresh-secret', 'unresolved-session']) + assert.ok(!JSON.stringify(saved).includes(secret)) +}) + +test('POST carton-label PDF is a definite binary response with durable hash and permits same-fixture readback', async () => { + const saved = [], calls = [], response = { http: 200, body: null, bytes: Buffer.from('%PDF-1.7\nfixture'), + contentType: 'application/pdf', requestId: 'r', traceId: 't' } + const ctx = { async requestBinary(method, path) { + assert.equal(saved.at(-1).status, 'SENDING'); calls.push(path); return response + }, async request() { calls.push('readback') } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + const path = '/api/platform/inbounds/1/carton-label-pdf?tenantId=2&cartonIndex=1' + assert.equal(await ctx.requestBinary('POST', path), response) + assert.equal(saved[0].path, path); assert.equal(saved[1].status, 'RESPONDED') + assert.equal(saved[1].binary.bytes, response.bytes.length); assert.match(saved[1].binary.sha256, /^[a-f0-9]{64}$/) + assert.equal(saved[1].binary.validPdf, true) + await ctx.request('GET', '/api/platform/inbounds/1'); assert.deepEqual(calls, [path, 'readback']) +}) + +test('real inbound prerequisite and AcceptanceRun binary transport accept three PDF writes then stop raw multipart retries', async t => { + const calls = [], saved = [], summary = { id: '1', tenantId: '2', processStatus: 'WAIT_MEASURE', inboundCode: 'FIXTURE-INBOUND', totalPackageCount: 2 } + const native = t.mock.method(globalThis, 'fetch', async (input, init) => { + const req = new Request(input, init), path = new URL(req.url).pathname + calls.push({ method: req.method, path }) + const headers = { 'x-request-id': 'r', 'x-trace-id': 't' } + if (req.method === 'GET') return new Response(JSON.stringify({ code: '200', data: { summary } }), { headers: { ...headers, 'content-type': 'application/json' } }) + assert.equal(saved.at(-1).status, 'SENDING') + if (path.endsWith('/carton-label-pdf')) return new Response('%PDF-1.7\nfixture', { headers: { ...headers, 'content-type': 'application/pdf' } }) + assert.ok(path.endsWith('/measurement/carton-scans')) + throw Error('transport failure Bearer sensitive-session https://example.invalid/private') + }) + const context = new AcceptanceRun({ gateway: 'https://test.invalid' }) + context.createdResources.orders = { marker: context.marker, inboundId: '1', tenantId: '2' } + const restore = guardFleetWrites(context, entry => saved.push(redactFleetWrite(structuredClone(entry))), { guardFetch: true }) + try { + await runInboundMeasurement(context) + assert.equal(calls.filter(row => row.path.endsWith('/carton-label-pdf')).length, 3) + assert.equal(calls.filter(row => row.path.endsWith('/measurement/carton-scans')).length, 1, 'raw helper retries must not reach transport') + const pdfs = saved.filter(row => row.binary?.validPdf) + assert.equal(pdfs.length, 3); assert.ok(pdfs.every(row => row.status === 'RESPONDED')) + assert.equal(context.steps.find(row => row.name === '生成箱唛 PDF 并冻结稳定箱码').passed, true) + const last = saved.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT') + assert.ok(last.body.multipart.some(row => row.field === 'data' && row.base64)) + assert.ok(last.raw.includes('transport failure')); assert.ok(!last.raw.includes('sensitive-session')); assert.ok(!last.raw.includes('example.invalid')) + await assert.rejects(context.request('POST', '/subsequent'), /transport failure/) + } finally { restore(); assert.equal(globalThis.fetch, native); t.mock.restoreAll() } +}) + +test('raw JSON prerequisite writes retain headers, original version and bounded diagnostics without duplicate journaling', async t => { + let calls = 0 + t.mock.method(globalThis, 'fetch', async () => { calls++; return new Response('x'.repeat(600), { status: 502 }) }) + const saved = [], context = new AcceptanceRun({ gateway: 'https://test.invalid' }) + const restore = guardFleetWrites(context, entry => saved.push(redactFleetWrite(structuredClone(entry))), { guardFetch: true }) + try { + await assert.rejects(fetch('https://test.invalid/approve', { method: 'PUT', headers: { 'content-type': 'application/json', + Authorization: 'Bearer secret', 'Idempotency-Key': 'same-key' }, body: JSON.stringify({ expectedVersion: 6 }) }), /Unknown write outcome/) + assert.equal(saved.length, 2); assert.equal(saved[0].body.expectedVersion, 6) + assert.equal(saved[0].headers['idempotency-key'], 'same-key'); assert.equal(saved[0].headers.authorization, '[REDACTED]') + assert.equal(saved[1].raw.length, 500) + await assert.rejects(fetch('https://test.invalid/retry', { method: 'POST' }), /Unknown write outcome/) + assert.equal(calls, 1) + } finally { restore(); t.mock.restoreAll() } +}) + +for (const override of [{ http: 0 }, { http: 503 }, { bytes: Buffer.from('proxy') }, + { bytes: new Uint8Array() }, { contentType: 'text/html' }, { traceId: null }, + { contentType: 'application/json', bytes: Buffer.from('{"code":"200"}'), body: { code: '200' } }]) + test(`binary write still stops on unknown or invalid response: ${JSON.stringify(override)}`, async () => { + const saved = [], ctx = { async requestBinary() { return { http: 200, body: null, bytes: Buffer.from('%PDF-1.7'), + contentType: 'application/pdf', requestId: 'r', traceId: 't', ...override } }, async request() { assert.fail('must stop') } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + await assert.rejects(ctx.requestBinary('POST', '/carton-label-pdf'), /Unknown write outcome/) + assert.equal(saved[1].status, 'UNKNOWN_RESULT') + await assert.rejects(ctx.request('GET', '/readback'), /Unknown write outcome/) + }) + test('checkpoint failure stops before dispatch; transport throw stops even after caller catches', async () => { let calls = 0 const ctx = { async request() { calls++; throw Error('transport failure') } } -- 2.54.0 From e5af1ba7581052fe552f4ce92c978b166e8ba68c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 19:27:03 +0800 Subject: [PATCH 114/176] test(fleet): handle PDFs from direct print requests --- scripts/fleet-test-env-acceptance.mjs | 12 +++++---- scripts/fleet-test-env-acceptance.test.mjs | 29 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 30ed00d3a..ba63348d4 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -30,10 +30,11 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch const direct = { async requestWithBody(method, path, body, token, headers, request) { const nativeResponse = await originalFetch(request), copy = nativeResponse.clone() - const content = await copy.text() + const bytes = new Uint8Array(await copy.arrayBuffer()), contentType = nativeResponse.headers.get('content-type') || '' + const content = /json|text|xml|html/i.test(contentType) ? new TextDecoder().decode(bytes) : '' let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic only. */ } - return { nativeResponse, http: nativeResponse.status, body: payload, raw: payload ? null : content.slice(0, 500), + return { nativeResponse, http: nativeResponse.status, body: payload, bytes, contentType, raw: payload || !content ? null : content.slice(0, 500), requestId: payload?.requestId || nativeResponse.headers.get('x-request-id'), traceId: payload?.traceId || nativeResponse.headers.get('x-trace-id') } } } @@ -65,13 +66,14 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { try { const response = await scope.run(true, () => original.apply(this, arguments)) const http = response?.http, payload = response?.body, code = id(payload?.code) - const binary = name === 'requestBinary' && response?.bytes instanceof Uint8Array - const pdf = binary && http === 200 && /^application\/pdf(?:\s*;|$)/i.test(response.contentType || '') + const expectsPdf = name === 'requestBinary' || owner === direct && /(?:^|,)\s*application\/pdf(?:\s*[;,]|$)/i.test(headers?.accept || '') + const binary = (name === 'requestBinary' || owner === direct) && response?.bytes instanceof Uint8Array + const pdf = expectsPdf && binary && http === 200 && /^application\/pdf(?:\s*;|$)/i.test(response.contentType || '') && response.bytes.length > 4 && Buffer.from(response.bytes.subarray(0, 5)).toString('ascii') === '%PDF-' const definite = Number.isInteger(http) && http >= 200 && http < 500 && response.requestId && response.traceId && (pdf || payload && typeof payload === 'object' && !Array.isArray(payload) - && (name !== 'requestBinary' && http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400))) + && (!expectsPdf && http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400))) Object.assign(entry, { status: definite ? 'RESPONDED' : 'UNKNOWN_RESULT', http, code, requestId: response?.requestId, traceId: response?.traceId, raw: boundedFleetDiagnostic(response?.raw), responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() }) diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 0e28c93ba..e7cd4baf8 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join, dirname, resolve } from 'node:path' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs' +import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs' import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs' for (const response of [ @@ -112,6 +113,34 @@ test('raw JSON prerequisite writes retain headers, original version and bounded } finally { restore(); t.mock.restoreAll() } }) +for (const invalidPdf of [false, true, 'zip']) test(`real loading finalize prints both PDFs through guarded direct fetch; invalid=${invalidPdf}`, async t => { + const saved = [], calls = [], fixture = { destinationType: 'FBA', warehouseId: '1', containerNo: 'CONTAINER', cabinetNo: 'CABINET' } + const manifest = { containerNo: fixture.containerNo, totalCount: 1, groups: [{ destinationWarehouseCode: 'FBA', labels: [{ + containerNo: fixture.containerNo, destinationWarehouseInternalCode: 'F01', palletIndex: 1, palletCode: 'CABINET-F01-001' }] }] } + t.mock.method(globalThis, 'fetch', async (input, init) => { + const req = new Request(input, init); calls.push(req.url) + assert.equal(saved.at(-1).status, 'SENDING') + const headers = { 'x-request-id': 'r', 'x-trace-id': 't' } + return req.url.includes('/prepare?') + ? new Response(JSON.stringify({ code: '200', data: manifest }), { headers: { ...headers, 'content-type': 'application/json' } }) + : new Response(invalidPdf === true ? 'proxy failure' : '%PDF-1.6\nfixture', + { headers: { ...headers, 'content-type': invalidPdf === 'zip' ? 'application/zip' : 'application/pdf' } }) + }) + const ctx = new AcceptanceRun({ gateway: 'https://test.invalid' }) + const restore = guardFleetWrites(ctx, entry => saved.push(redactFleetWrite(structuredClone(entry))), { guardFetch: true }) + try { + await verifyPalletLabels(ctx, fixture, '2') + const prints = saved.filter(row => row.path.includes('/print?')) + assert.equal(calls.filter(url => url.includes('/print?')).length, invalidPdf ? 1 : 2) + assert.equal(prints.at(-1).status, invalidPdf ? 'UNKNOWN_RESULT' : 'RESPONDED') + assert.equal(prints.at(-1).binary.validPdf, !invalidPdf) + assert.equal(prints.at(-1).raw, null, 'binary response bytes must not enter text evidence') + assert.equal(prints[0].body.destinationWarehouseCode, null) + if (!invalidPdf) { assert.equal(prints[2].body.destinationWarehouseCode, 'FBA'); assert.ok(ctx.steps.every(row => row.passed)) } + else await assert.rejects(ctx.request('POST', '/no-retry'), /Unknown write outcome/) + } finally { restore(); t.mock.restoreAll() } +}) + for (const override of [{ http: 0 }, { http: 503 }, { bytes: Buffer.from('proxy') }, { bytes: new Uint8Array() }, { contentType: 'text/html' }, { traceId: null }, { contentType: 'application/json', bytes: Buffer.from('{"code":"200"}'), body: { code: '200' } }]) -- 2.54.0 From bab967acce842b1057e9ea43628b959806819082 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 19:51:29 +0800 Subject: [PATCH 115/176] test(fleet): preserve unavailable allocations for multi-stop trips --- ...eet-trip-cost-acceptance-contract.test.mjs | 66 +++++++++++++++++-- .../fleet-trip-cost-acceptance.mjs | 28 ++++++-- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs index 911d10464..ab09210bf 100644 --- a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs @@ -4,7 +4,7 @@ import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } fro const copy = value => value === undefined ? undefined : structuredClone(value) const prefix = 'platform:fleet:trip:' -function fixture(fault) { +function fixture(fault, multiDestination = false) { const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map() const shares = [ { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] }, @@ -12,25 +12,49 @@ function fixture(fault) { ] const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' } const depart = { recordVersion: 7, action: 'DEPART', snapshot: { - trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z' }, + trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z', + stops: [{ name: 'Origin' }, { destinationKey: 'FBA:A' }, { destinationKey: 'FBA:B' }] }, confirmedLoad: { manifestHash: 'original-manifest', pallets: [ { warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }], - cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5 }, - { warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9 }] }, + cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5, destinationKey: 'FBA:A' }, + { warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] }, externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND', receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } } + const load = depart.snapshot.confirmedLoad + if (multiDestination) { + shares[0].warehousePalletIds.push('P3') + load.pallets[0].palletSpaces = 1 + load.pallets.push({ warehousePalletId: 'P3', palletSpaces: 1, deliveryIndex: 2 }) + load.cargo[0].packages = 1 + load.cargo.push({ warehousePalletId: 'P3', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 4, destinationKey: 'FBA:B' }) + } + if (fault === 'duplicate pallet') load.pallets.push(copy(load.pallets[0])) + if (fault === 'overlapping shares') shares[1].warehousePalletIds.push('P1') + if (fault === 'missing pallet') load.pallets.pop() + if (fault === 'uncovered cargo') load.cargo.push({ ...load.cargo[0], warehousePalletId: 'UNKNOWN' }) + if (fault === 'mixed pallet destinations') load.cargo.push({ ...load.cargo[0], destinationKey: 'FBA:B' }) + if (fault === 'origin stop') load.pallets[0].deliveryIndex = 0 + if (fault === 'out of range stop') load.pallets[0].deliveryIndex = 3 + if (fault === 'fractional stop') load.pallets[0].deliveryIndex = 1.5 + if (fault === 'missing destination') delete load.cargo[0].destinationKey + if (fault === 'missing stops') delete depart.snapshot.trip.stops if (fault === 'unbound external') depart.snapshot.externalBookings[0].status = 'CONFIRMED' if (fault === 'wrong stop') depart.snapshot.confirmedLoad.pallets[1].deliveryIndex = 2 if (fault === 'wrong receipt') depart.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 8 if (fault === 'wrong weights') shares[0].palletSpaces = 3 function summary() { + const unavailable = multiDestination && fault !== 'forced allocation' return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest', - allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationBlockReason: null, + allocationMethod: unavailable ? null : 'SAME_ROUTE_PALLET_SPACES', + allocationBlockReason: unavailable ? fault === 'wrong block reason' ? 'other' : '各票卸货站不同,暂不可按同路线托位自动分摊' : null, totals: ['CNY', 'USD'].filter(currency => rows.some(row => row.details.currency === currency)).map(currency => { const amount = rows.filter(row => row.details.currency === currency).reduce((sum, row) => sum + Math.round(Number(row.details.amount) * 100), 0) // Hard-coded independently calculated expected cent splits for 2:1 physical spaces. const own = currency === 'USD' ? 871 : amount === 10003 ? 6669 : 6667 const external = currency === 'USD' ? 436 : 3334 + if (unavailable) return { currency, directCost: (amount + (fault === 'wrong direct total' ? 1 : 0)) / 100, + ownCost: fault === 'zero instead of unavailable' ? 0 : null, externalCost: null, + allocations: fault === 'invented allocation' ? [{ businessNo: 'DP-OWN', amount: amount / 100 }] : [] } return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [ { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 }, { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL', @@ -103,6 +127,38 @@ test('cost expectation uses stable largest remainder and conserves every cent in assert.equal(TRIP_COST_EXPECTATIONS.finalCny, '100.03') }) +test('multi-destination records and corrects whole-trip costs while allocations remain unavailable', async () => { + const f = fixture(undefined, true), proof = await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs) + assert.equal(proof.status, 'VERIFIED') + assert.deepEqual(proof.departure.snapshot.confirmedLoad.pallets.map(row => row.deliveryIndex), [1, 1, 2]) + assert.deepEqual(proof.final.summary.totals.map(row => [row.currency, row.directCost, row.ownCost, row.externalCost]), + [['CNY', 100.03, null, null], ['USD', 13.07, null, null]]) + assert.ok(proof.final.summary.totals.every(row => row.allocations.length === 0)) + assert.equal(proof.expected.allocationStatus, 'UNAVAILABLE') + assert.equal(proof.expected.allocationMethod, null) + assert.equal(proof.coverage.allocation, 'MULTI_STOP_UNAVAILABLE_VERIFIED') + assert.equal(proof.coverage.convertedCnyStatistics, 'NOT_TESTED_BY_THIS_MODULE') +}) + +for (const fault of ['forced allocation', 'wrong block reason', 'wrong direct total', 'zero instead of unavailable', 'invented allocation']) { + test(`multi-destination rejects ${fault}`, async () => { + const f = fixture(fault, true) + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)) + assert.equal(f.trip.costAcceptance.status, 'FAILED') + }) +} + +for (const fault of ['duplicate pallet', 'overlapping shares', 'missing pallet', 'uncovered cargo', + 'mixed pallet destinations', 'origin stop', 'out of range stop', 'fractional stop', 'missing destination', 'missing stops', 'wrong stop']) { + test(`multi-destination ${fault} fails before any cost write`, async () => { + const f = fixture(fault, true) + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)) + assert.equal(f.trip.costAcceptance.status, 'FAILED') + assert.ok(f.calls.every(call => call.method === 'GET')) + assert.equal(f.rows.length, 0) + }) +} + for (const fault of ['unbound external', 'wrong stop', 'wrong receipt', 'wrong weights', 'wrong departure', 'external counted own', 'lost cent', 'reader is admin', 'denial is outage', 'permission bypass', 'history overwritten', 'history summary drift', 'replay duplicate', 'creation overwrites correction', 'stale accepted', 'noop audit']) { diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs index 2af2ed5b6..6d22fa726 100644 --- a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' const OPTIONS = { scenario: 'fleet-trip-operating-cost', deploymentBlocker: true } const PERM = 'platform:fleet:trip:' const AUTH = '/api/platform/v1/iam/authorization/permissions' +const MULTI_STOP_REASON = '各票卸货站不同,暂不可按同路线托位自动分摊' const clone = value => value === undefined ? undefined : structuredClone(value) const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) const sorted = rows => [...rows].sort((a, b) => a.businessNo < b.businessNo ? -1 : a.businessNo > b.businessNo ? 1 : 0) @@ -30,7 +31,7 @@ export function expectedAllocations(amount, shares) { * C calls after real DEPART + replay, before releasing the fixture. No entry registration or IAM mutation. * tripProof must be the object in createdResources.fleetTrips and fleetAcceptance.departure must match. * inputs.shares is the caller's independently known OWN dispatch / EXTERNAL booking mapping: - * {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (same unloading stop). + * {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (may span unloading stops). * inputs.listOnlyToken/deniedToken are current-run platform sessions owned and cleaned up by the caller. * Neither token is persisted. Missing actors/facts fail before cost writes. No automatic restart/cleanup. */ @@ -69,15 +70,22 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs = } catch (error) { if (entry.status === 'SENDING') entry.status = 'UNKNOWN_RESULT'; throw error } } const list = () => request('GET', `${path}?page=1&size=100`, undefined, '读取本趟成本及分摊') - let departure + let departure, sameRoute function verifySummary(summary, amounts) { check('分摊锁定原始DEPART版本和装载摘要', String(summary?.departureVersion) === String(departure.recordVersion) && summary.manifestHash === departure.snapshot.confirmedLoad.manifestHash - && summary.allocationMethod === TRIP_COST_EXPECTATIONS.allocationMethod && summary.allocationBlockReason == null) + && (sameRoute + ? summary.allocationMethod === TRIP_COST_EXPECTATIONS.allocationMethod && summary.allocationBlockReason == null + : summary.allocationMethod == null && summary.allocationBlockReason === MULTI_STOP_REASON)) check('原币分别汇总,未混币', Array.isArray(summary.totals) && summary.totals.length === Object.keys(amounts).length && new Set(summary.totals.map(row => row.currency)).size === summary.totals.length) for (const row of summary.totals) { check('整趟成本与确定预期一致', amounts[row.currency] != null && cents(row.directCost) === cents(amounts[row.currency])) + if (!sameRoute) { + check('多目的地保留整趟原币成本,分票及自有外接成本未分摊', Array.isArray(row.allocations) + && row.allocations.length === 0 && row.ownCost == null && row.externalCost == null) + continue + } const expected = expectedAllocations(amounts[row.currency], shares) const actual = sorted(row.allocations).map(part => ({ businessNo: part.businessNo, sourceType: part.sourceType, dispatchNo: part.dispatchNo, palletSpaces: Number(part.palletSpaces), cents: String(cents(part.amount)) })) @@ -110,8 +118,18 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs = const ids = shares.flatMap(row => row.warehousePalletIds).map(String) check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length && same([...ids].sort(), load.pallets.map(row => String(row.warehousePalletId)).sort()) - && new Set(load.pallets.map(row => row.deliveryIndex)).size === 1 && load.cargo.every(row => ids.includes(String(row.warehousePalletId)))) + const stops = snapshot.trip.stops + check('逐托目的站索引与DEPART货物目的地一致', Array.isArray(stops) && load.pallets.every(pallet => { + const index = pallet.deliveryIndex + const cargo = load.cargo.filter(row => String(row.warehousePalletId) === String(pallet.warehousePalletId)) + return Number.isSafeInteger(index) && index > 0 && index < stops.length && cargo.length > 0 + && Boolean(stops[index]?.destinationKey) + && cargo.every(row => row.destinationKey === stops[index].destinationKey) + })) + sameRoute = new Set(load.pallets.map(row => row.deliveryIndex)).size === 1 + Object.assign(proof.expected, { allocationMethod: sameRoute ? TRIP_COST_EXPECTATIONS.allocationMethod : null, + allocationStatus: sameRoute ? 'AVAILABLE' : 'UNAVAILABLE', allocationBlockReason: sameRoute ? null : MULTI_STOP_REASON }) const external = (snapshot.externalBookings || []).filter(row => !['DRAFT', 'CANCELLED'].includes(row.status)) check('有效外接预订全为BOUND且预期完整', external.every(row => row.status === 'BOUND') && same(external.map(row => row.bookingNo).sort(), shares.filter(row => row.sourceType === 'EXTERNAL').map(row => row.businessNo).sort())) @@ -179,6 +197,8 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs = (proof.histories ??= []).push({ costNo: row.costNo, records: clone(rows) }) } proof.coverage = { currencies: ['CNY', 'USD'], sourceTypes: [...new Set(shares.map(row => row.sourceType))], + allocation: sameRoute ? 'SAME_ROUTE_CONSERVATION_VERIFIED' : 'MULTI_STOP_UNAVAILABLE_VERIFIED', + convertedCnyStatistics: 'NOT_TESTED_BY_THIS_MODULE', permissions: 'WRITER_READER_DENIED', customerFinance: 'NOT_TESTED_BY_THIS_MODULE' } proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof } catch (error) { proof.status = 'FAILED'; await context.checkpoint?.(); throw error } -- 2.54.0 From 6d922516f6e2606b5aec1e1fad67e69ba335d609 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 20:06:02 +0800 Subject: [PATCH 116/176] test(fleet): resume reviewed post-departure acceptance safely --- scripts/fleet-test-env-acceptance.mjs | 153 +++++++++++++++++++-- scripts/fleet-test-env-acceptance.test.mjs | 118 +++++++++++++++- 2 files changed, 256 insertions(+), 15 deletions(-) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index ba63348d4..71cb3c3f5 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -140,7 +140,7 @@ export function parseFleetArguments(args) { if (action) throw Error('Choose exactly one plan or execute action') action = name; config.execute = name === '--execute'; continue } - if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') + if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--resume-sha256', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') if (seen.has(name)) throw Error('Duplicate argument') seen.add(name) const value = args[++index] @@ -148,12 +148,15 @@ export function parseFleetArguments(args) { else if (name === '--run-id') config.runId = value else if (name === '--output') config.outputDir = value else if (name === '--resume-report') config.resumeReport = value + else if (name === '--resume-sha256') config.resumeSha256 = value else if (name === '--scenario') config.scenario = value else config.resumeStage = value } if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') - if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) + throw Error('Departed recovery requires the exact reviewed report SHA256') if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport)) throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint') @@ -163,9 +166,13 @@ export function fleetExecutionPlan(config) { return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover', returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full', stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, - outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites', - resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, - stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', + outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', + resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, + stages: config.resumeStage === 'DEPARTED_BEFORE_COSTS' + ? ['Verify reviewed report SHA256', 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', + 'Create fresh bounded cost/PDA sessions', 'Original trip operating costs with actual allocation availability', + 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] + : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', 'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', 'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'], @@ -176,6 +183,20 @@ export function fleetExecutionPlan(config) { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) + if (stage === 'DEPARTED_BEFORE_COSTS') { + const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false) + if (report?.success !== false || proof?.stage !== 'TRIP_OPERATING_COSTS' || proof.status !== 'CHECKING' + || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo + || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash + || costs.status !== 'FAILED' || !Array.isArray(costs.costs) || costs.costs.length || !costs.requests?.length + || costs.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') + || trip.handover || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch + || resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId + || failures?.length !== 2 || !failures.some(row => row.name === '独立预期覆盖全部实物且没有重叠托盘') + || failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].includes(row.name))) + throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes') + return { resources, marker: proof.marker, departed: true } + } if (stage === 'TERMINAL_READONLY') { const failures = report?.steps?.filter(row => row.passed === false) if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE' @@ -276,6 +297,66 @@ export async function verifyFleetTerminalState(context) { return proof } +/** All business checks happen before creating replacement test sessions or issuing a business command. */ +export async function verifyDepartedRecovery(context) { + const proof = context.createdResources.fleetAcceptance + const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` + const evidence = { status: 'CHECKING', tripNo: trip.tripNo, requests: [], dispatchDetails: [], deliveries: [] } + context.createdResources.fleetDepartureRecoveryVerification = evidence + const check = (ok, message) => { if (!ok) throw Error(`Departed recovery refused: ${message}`) } + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) + async function get(path) { + const r = await context.request('GET', path) + evidence.requests.push({ path, http: r.http, code: r.body?.code, requestId: r.requestId, traceId: r.traceId }) + check(success(r) && r.requestId && r.traceId, 'read-only response not definitive') + return r.body.data + } + const current = await get(base) + check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(snapshot.trip.recordVersion) + && current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId) + && id(current.trip.vehicleId) === id(snapshot.trip.vehicleId) && id(current.trip.driverId) === id(snapshot.trip.driverId) + && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') + const history = await get(`${base}/history?page=1&size=100`) + const departures = history.records?.filter(row => row.action === 'DEPART') + check(Array.isArray(history.records) && Number(history.total) === history.records.length + && departures?.length === 1 && same(departures[0], original) + && history.records.every(row => BigInt(row.recordVersion) <= BigInt(original.recordVersion)), 'immutable DEPART/history changed') + const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`) + check(costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0 + && costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed') + check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') + check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) + && current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed') + for (const number of proof.dispatchNos) { + const detail = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) + const cargo = load.cargo.filter(row => row.dispatchNo === number) + const facts = rows => rows.map(row => [id(row.id ?? row.dispatchItemId), id(row.warehousePalletId), Number(row.packageCount ?? row.packages)]) + .sort((a, b) => a[0].localeCompare(b[0])) + check(detail.dispatchNo === number && detail.status === 'DEPARTED' && id(detail.overseasWarehouseId) === id(proof.warehouseId) + && Number(detail.packageCount) === Number(detail.shippedPackageCount) && Number(detail.pickedPackageCount) === Number(detail.shippedPackageCount) + && same(facts(detail.items), facts(cargo)), 'dispatch shipped cargo changed') + evidence.dispatchDetails.push(detail) + } + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const deliveryNos = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo] + check(new Set(deliveryNos).size === deliveryNos.length, 'duplicate delivery identities') + for (const number of deliveryNos) { + const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) + const order = detail.order + check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo) + && order.status === 'WAIT_DELIVERY' && id(order.version) === '1' && !order.signedAt && !Number(order.signedPackages || 0) + && !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed') + evidence.deliveries.push(order) + } + check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch') + Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash, + departure: departures[0], costSummary: costs.summary }) + await context.checkpoint() + return { trip, dispatchDetails: evidence.dispatchDetails } +} + export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) { const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails) const run = context.createdResources.fleetAcceptance @@ -287,6 +368,8 @@ export async function runFleetHandoverStage(context, trip, pickerToken, dispatch departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo, originalResources: state.previous, replacementResources: state.replacement, browser: state.browser, costs: trip.costAcceptance, + allocationStatus: trip.costAcceptance.expected?.allocationStatus, + allocationBlockReason: trip.costAcceptance.expected?.allocationBlockReason, status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount } context.context?.saveFleetHandoverFixture?.(fixture) if (onReady) await onReady(fixture) @@ -532,10 +615,24 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) proof.departure = departed + await completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady }) + } finally { await disablePicker(context, picker, 71) } + return proof +} + +export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, + verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, + closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { + const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const external = trip.externalDispatch, dispatchNos = proof.dispatchNos + const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) } + const check = (label, ok) => { if (!ok) throw Error(label) } + async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data } stage('TRIP_OPERATING_COSTS') - await verifyFleetTripCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) stage('IN_TRANSIT_HANDOVER') - await runFleetHandoverStage(context, trip, picker.token, dispatchDetails, { + await handover(context, trip, picker.token, dispatchDetails, { onReady: onHandoverReady ? async fixture => { stage('PDA_UI_CHECKPOINT') await onHandoverReady({ fixture, picker }) @@ -553,20 +650,50 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, undefined, '签收前读取本次实际派送') - const completed = await saveReceiptAndComplete(context, delivery.order, customerId, index + 71) + const completed = await completeDelivery(context, delivery.order, customerId, index + 71) check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) } } - proof.closure = await finishFleetTrip(context, MODULE, trip) + proof.closure = await closeTrip(context, MODULE, trip) check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') stage('HANDOVER_RESOURCE_CLEANUP') - await cleanupFleetHandoverResources(context, MODULE, trip) + await cleanup(context, MODULE, trip) proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') - } finally { await disablePicker(context, picker, 71) } return proof } +export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, + resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, + complete = completeDepartedFleetScenario } = {}) { + const verified = await verify(context) + const proof = context.createdResources.fleetAcceptance, trip = verified.trip + context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors + trip.previousReadOnlyCostAttempts = [trip.costAcceptance] + proof.previousPicker = proof.picker + delete context.createdResources.fleetCostActors + delete trip.costAcceptance + delete proof.picker + proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}` + await context.checkpoint() + let actors, picker + try { + actors = await prepareActors(context) + if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken + || actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token)) + throw Error('Recovery requires fresh independent cost sessions') + const pickerContext = Object.create(context); pickerContext.marker = proof.pickerMarker + const access = await resolveAccess(context) + picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null + if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session') + proof.picker = { membershipId: picker.membershipId, username: picker.username } + await context.checkpoint() + return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors }) + } finally { + try { await closePicker(context, picker, 71) } finally { await actors?.close() } + } +} + export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) { if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test' || basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required') @@ -594,6 +721,8 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous) if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports') const bytes = readFileSync(previous) + if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) + throw Error('Reviewed departure report SHA256 mismatch; no environment request sent') resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage) save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage }) } else { @@ -628,6 +757,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c } context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) const fleetScenario = async (ctx, options = {}) => { + if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors }) const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) try { return await scenario(ctx, { ...options, costActors }) } finally { await costActors?.close() } @@ -653,6 +783,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed') context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, + resumeDeparted: resumed.departed, resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index e7cd4baf8..84ffe7ff7 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -1,12 +1,122 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, dirname, resolve } from 'node:path' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs' import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs' -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs' + +function departedRecoveryFixture() { + const snapshotTrip = { tripNo: 'TR-OWN', status: 'IN_TRANSIT', recordVersion: '27', vehicleId: 'V1', driverId: 'D1', warehouseId: 'W1', departedAt: '2026-10-03T11:00:00Z' } + const cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'I1', warehousePalletId: 'P1', packages: 2 }, { dispatchNo: 'EXT', dispatchItemId: 'I2', warehousePalletId: 'P2', packages: 1 }] + const departure = { action: 'DEPART', recordVersion: '27', snapshot: { trip: snapshotTrip, confirmedLoad: { manifestHash: 'a'.repeat(64), cargo } } } + const proof = { marker: 'RUN', tripNo: 'TR-OWN', warehouseId: 'W1', customerId: 'C1', status: 'CHECKING', stage: 'TRIP_OPERATING_COSTS', checkpoints: [], + dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' }, + departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } } + const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure, + requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] }, + externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } } + const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }], + createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } } + const base = '/api/platform/v1/last-leg/fleet/trips/TR-OWN' + const responses = new Map([[base, { trip: structuredClone(snapshotTrip), dispatches: [{ dispatchNo: 'OWN', customerId: 'C1' }, { dispatchNo: 'EXT', customerId: 'C1' }] }], + [base + '/history?page=1&size=100', { total: 1, records: [structuredClone(departure)] }], + [base + '/costs?page=1&size=100', { page: { records: [], total: 0 }, summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [] } }], + [base + '/handovers', { records: [], total: 0 }]]) + for (const row of cargo) { + responses.set('/api/platform/v1/last-leg/dispatches/' + row.dispatchNo, { dispatchNo: row.dispatchNo, status: 'DEPARTED', overseasWarehouseId: 'W1', + packageCount: row.packages, pickedPackageCount: row.packages, shippedPackageCount: row.packages, + items: [{ id: row.dispatchItemId, warehousePalletId: row.warehousePalletId, packageCount: row.packages }] }) + responses.set('/api/platform/v1/last-leg/delivery-orders/DL-' + row.dispatchNo + '?customerId=C1', { order: { deliveryNo: 'DL-' + row.dispatchNo, + customerId: 'C1', dispatchNo: row.dispatchNo, status: 'WAIT_DELIVERY', version: '1', packageCount: row.packages, + documentSummary: { categories: [{ fileCount: 0 }] } } }) + } + const calls = [], ctx = { marker: 'RUN', token: 'writer', context: {}, createdResources: report.createdResources, async checkpoint() { calls.push('checkpoint') }, + async request(method, path) { assert.equal(method, 'GET', 'recovery verification must never write'); calls.push(path); assert.ok(responses.has(path), path) + return { http: 200, body: { code: '200', data: responses.get(path) }, requestId: 'r', traceId: 't' } } } + return { report, ctx, calls, responses, base, trip } +} + +test('departed recovery requires reviewed SHA and exactly the original read-only cost failure', () => { + assert.throws(() => parseFleetArguments(['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS']), /SHA256/) + const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)] + assert.equal(parseFleetArguments(args).resumeSha256, 'a'.repeat(64)) + assert.throws(() => parseFleetArguments([...args, '--scenario', 'return-redispatch']), /fresh run/) + assert.equal(reviewedFullCheckpoint(departedRecoveryFixture().report, 'DEPARTED_BEFORE_COSTS').departed, true) + for (const change of [r => r.createdResources.fleetTrips[0].costAcceptance.requests.push({ method: 'POST' }), + r => r.createdResources.fleetTrips[0].handover = {}, r => r.createdResources.fleetAcceptance.receipts = [], + r => r.createdResources.fleetCostActors.status = 'READY', r => r.steps[0].name = 'unknown write']) { + const { report } = departedRecoveryFixture(); change(report) + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_BEFORE_COSTS'), /checkpoint/) + } +}) + +for (const mutation of ['none', 'version', 'history', 'cost', 'handover', 'cargo', 'signature', 'document']) + test(`recovery rechecks actual state before any session/business write: ${mutation}`, async () => { + const { ctx, responses, base } = departedRecoveryFixture() + if (mutation === 'version') responses.get(base).trip.recordVersion = '28' + if (mutation === 'history') responses.get(base + '/history?page=1&size=100').records[0].snapshot.confirmedLoad.manifestHash = 'changed' + if (mutation === 'cost') responses.get(base + '/costs?page=1&size=100').page.records.push({ costNo: 'COST' }) + if (mutation === 'handover') responses.get(base + '/handovers').records.push({ handoverNo: 'H' }) + if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN').items[0].packageCount = 3 + const delivery = responses.get('/api/platform/v1/last-leg/delivery-orders/DL-OWN?customerId=C1').order + if (mutation === 'signature') delivery.signedAt = 'now' + if (mutation === 'document') delivery.documentSummary.categories[0].fileCount = 1 + if (mutation === 'none') { await verifyDepartedRecovery(ctx); assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED') } + else await assert.rejects(continueReviewedDeparture(ctx, { prepareActors: () => assert.fail('no new actors before validation') }), /recovery refused/) + }) + +test('reviewed recovery archives old evidence and creates fresh sessions before shared post-departure flow only', async () => { + const { ctx, trip, calls } = departedRecoveryFixture(); const oldCost = trip.costAcceptance + await continueReviewedDeparture(ctx, { + prepareActors: async () => { assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED'); calls.push('new-actors'); return { marker: 'RUN', listOnlyToken: 'reader', deniedToken: 'denied', async close() { calls.push('close-actors') } } }, + resolveAccess: async () => ({}), makePicker: async child => { assert.match(child.marker, /RECOVERY-PDA/); calls.push('new-picker'); return { membershipId: 'NEW', token: 'new-pda' } }, + closePicker: async () => calls.push('close-picker'), + complete: async (context, actualTrip, picker, dispatches) => { + assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 2) + assert.equal(trip.previousReadOnlyCostAttempts[0], oldCost); assert.equal(context.createdResources.fleetAcceptance.previousPicker.membershipId, 'OLD-PDA') + calls.push('continue-from-costs') + }, + }) + assert.deepEqual(calls.filter(x => !x.startsWith('/') && x !== 'checkpoint'), ['new-actors', 'new-picker', 'continue-from-costs', 'close-picker', 'close-actors']) +}) + +test('shared continuation starts at costs, retains unavailable allocation and never enters picking or DEPART', async () => { + const { ctx, trip, calls } = departedRecoveryFixture() + const verified = await verifyDepartedRecovery(ctx), operations = [] + await completeDepartedFleetScenario(ctx, trip, { token: 'pda' }, verified.dispatchDetails, { + costActors: { marker: 'RUN' }, + verifyCosts: async () => { operations.push('costs'); trip.costAcceptance = { status: 'VERIFIED', expected: { allocationStatus: 'UNAVAILABLE' } } }, + handover: async () => { assert.equal(trip.costAcceptance.expected.allocationStatus, 'UNAVAILABLE'); operations.push('handover') }, + completeDelivery: async (context, order) => { operations.push('signature:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } }, + closeTrip: async () => { operations.push('close-trip'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, + cleanup: async () => operations.push('cleanup'), + }) + assert.deepEqual(operations, ['costs', 'handover', 'signature:DL-OWN', 'signature:DL-EXT', 'close-trip', 'cleanup']) + assert.equal(ctx.createdResources.fleetAcceptance.stage, 'FINISHED') + assert.ok(!calls.some(x => /\/depart$|\/pick|\/loading/.test(x))) +}) + +test('changed source report SHA blocks recovery before creating context or logging in', async () => { + const temporary = mkdtempSync(join(tmpdir(), 'fleet-recovery-sha-')) + const root = join(temporary, 'chongchong-global', 'baiyue-firstleg'); mkdirSync(join(root, 'reports', 'original'), { recursive: true }) + writeFileSync(join(root, 'reports', 'original', 'report.json'), JSON.stringify(departedRecoveryFixture().report)) + try { + const result = await runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'reviewed', resumeStage: 'DEPARTED_BEFORE_COSTS', + resumeReport: 'reports/original/report.json', resumeSha256: 'b'.repeat(64) }, { root, + env: { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid', CHONGCHONG_ADMIN_USERNAME: 'admin', + CHONGCHONG_ADMIN_PASSWORD: 'secret', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'secret' }, + createContext: () => assert.fail('SHA mismatch must precede context/network'), + }) + assert.equal(result.success, false); assert.match(result.fatal, /SHA256 mismatch/) + } finally { + assert.equal(dirname(resolve(temporary)), resolve(tmpdir())) + assert.ok(resolve(temporary).startsWith(join(resolve(tmpdir()), 'fleet-recovery-sha-'))) + rmSync(temporary, { recursive: true, force: true }) + } +}) +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, @@ -281,8 +391,8 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") - const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await finishFleetTrip') - const cleanup = source.indexOf('await cleanupFleetHandoverResources(context, MODULE, trip)') + const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip') + const cleanup = source.indexOf('await cleanup(context, MODULE, trip)') assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) }) -- 2.54.0 From f56c68d95241e8f1c4098c5d34d52d5eb6cb958f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 20:13:50 +0800 Subject: [PATCH 117/176] test(fleet): isolate recovery actor phone generation --- scripts/fleet-test-env-acceptance.mjs | 19 ++++++++++++++- scripts/fleet-test-env-acceptance.test.mjs | 28 +++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 71cb3c3f5..13e8040da 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -448,7 +448,7 @@ export async function prepareFleetCostActors(context) { await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] }) const password = `Fleet${randomUUID().replaceAll('-', '')}A9` const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`, - phone: await context.findAvailablePhone(MODULE), email: `${username}@example.com`, tenantId: '0', + phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [actor.roleId], password, status: 'ACTIVE' }) if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0' || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch') @@ -469,6 +469,23 @@ export async function prepareFleetCostActors(context) { } } +export async function findFleetActorPhone(context, actorSuffix) { + if (!context.marker || !/^[a-f0-9]{16}$/.test(actorSuffix)) throw Error('Fresh actor phone scope required') + for (let attempt = 0; attempt < 100; attempt++) { + const number = createHash('sha256').update(`${context.marker}:${actorSuffix}:${attempt}`).digest().readUInt32BE(0) + const phone = `18${String(number).padStart(9, '0').slice(-9)}` + const users = await context.request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '20', keyword: phone })}`) + if (!success(users) || !users.requestId || !users.traceId || !Array.isArray(users.body.data?.records) + || !Number.isSafeInteger(Number(users.body.data.total)) || Number(users.body.data.total) !== users.body.data.records.length) + throw Error('Cannot verify platform actor phone availability') + if (users.body.data.records.some(row => row.phone === phone)) continue + const customer = await context.request('GET', `/api/platform/v1/iam/tenants/administrator-phone-availability?phone=${encodeURIComponent(phone)}`) + if (!success(customer) || !customer.requestId || !customer.traceId) throw Error('Cannot verify customer administrator phone availability') + if (customer.body.data === true) return phone + } + throw Error('No available phone in the fresh actor scope; do not replay user creation') +} + export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady, costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { if (terminalReadOnly) return verifyFleetTerminalState(context) diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 84ffe7ff7..fc46aeb5f 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -116,7 +116,7 @@ test('changed source report SHA blocks recovery before creating context or loggi rmSync(temporary, { recursive: true, force: true }) } }) -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, @@ -357,6 +357,8 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r const context = { marker: 'RUN', token: 'admin-secret', createdResources: {}, async findAvailablePhone() { return '19900000000' }, async checkpoint() { saved.push(structuredClone(context.createdResources)) }, async request(method, path, body, token) { + if (method === 'GET' && (path.startsWith('/api/platform/v1/iam/users?') || path.includes('/administrator-phone-availability?'))) + return { http: 200, body: { code: '200', data: path.includes('/administrator-phone-availability?') ? true : { records: [], total: 0 } }, requestId: 'r', traceId: 't' } const pending = saved.at(-1).fleetCostActors.requests.at(-1) assert.equal(pending.status, 'SENDING'); assert.equal(pending.path, path) calls.push({ method, path, body, token }) @@ -388,6 +390,30 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r assert.ok(!calls.some(row => row.method === 'DELETE')) }) +test('fresh cost actor phones avoid existing platform users and use an independent namespace under the same original marker', async () => { + const phones = [], ctx = { marker: 'ORIGINAL-RUN', async request(method, path) { + assert.equal(method, 'GET') + let data + if (path.includes('/users?')) { + const phone = new URL('https://test.invalid' + path).searchParams.get('keyword'); phones.push(phone) + data = { records: phones.length === 1 ? [{ phone }] : [], total: phones.length === 1 ? 1 : 0 } + } else data = true + return { http: 200, body: { code: '200', data }, requestId: 'r', traceId: 't' } + } } + const first = await findFleetActorPhone(ctx, 'a'.repeat(16)) + assert.notEqual(first, phones[0]); assert.match(first, /^18\d{9}$/) + const second = await findFleetActorPhone(ctx, 'b'.repeat(16)) + assert.notEqual(first, second) +}) + +test('phone lookup failure stops without any user creation or guessing', async () => { + let calls = 0 + await assert.rejects(findFleetActorPhone({ marker: 'RUN', async request(method) { + assert.equal(method, 'GET'); calls++; return { http: 0 } + } }, 'a'.repeat(16)), /Cannot verify/) + assert.equal(calls, 1) +}) + test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") -- 2.54.0 From f2e25ec34b65c2ea9c48dfd99adf34aa87385850 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 20:24:56 +0800 Subject: [PATCH 118/176] fix(fleet): verify dispatch cargo and retain checked costs on continuation --- scripts/README.md | 4 + ...leet-handover-acceptance-contract.test.mjs | 9 ++- .../fleet-handover-acceptance.mjs | 8 +- scripts/fleet-test-env-acceptance.mjs | 80 ++++++++++++------- scripts/fleet-test-env-acceptance.test.mjs | 42 +++++++++- 5 files changed, 109 insertions(+), 34 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 0fdaff748..f5c0d7541 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -38,6 +38,10 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 +该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,仅接受全新批次。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 + +经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。 + `order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="" --env-file=""` 提供,数据库凭据只在执行节点内存读取。 diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs index 7d32e6a45..60e866482 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -12,8 +12,9 @@ function fixture(fault = '') { const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT', recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', - status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ inboundCode: `IN-${customer}`, packageCount: 10 }] })) - const liveDispatches = dispatchDetails.map(row => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) + status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, + items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] })) + const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' })) const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } } @@ -24,6 +25,8 @@ function fixture(fault = '') { const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) } const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId })) + if (fault === 'binding-customer') bindings[0].customerId = 'OTHER' + if (fault === 'dispatch-cargo') liveDispatches[0].items = [{ ...liveDispatches[0].items[0], id: 'OTHER-ITEM' }] const context = { context: { async checkpointFleetHandover() {} }, marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, @@ -207,7 +210,7 @@ test('every HTTP call observes a completed pre-send checkpoint; post-response di assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING') }) -for (const [fault, message] of Object.entries({ 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', +for (const [fault, message] of Object.entries({ 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs index 639c0d01b..b9817746c 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -61,7 +61,7 @@ function io(context, module, proof) { return { check, request } } function dispatchFacts(row) { - const fields = ['dispatchNo', 'customerId', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', + const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) } @@ -113,9 +113,13 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke for (const expected of dispatchDetails) { const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) + const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo) + const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId), + packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId)) check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED' && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) - && text(row.customerId) === text(binding.customerId) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) + && binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId)) + && same(items(row.items || []), items(cargo)) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) proof.dispatchBefore.push(dispatchFacts(row)) const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 13e8040da..86068aa70 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -154,8 +154,8 @@ export function parseFleetArguments(args) { } if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') - if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') - if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) + if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) throw Error('Departed recovery requires the exact reviewed report SHA256') if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport)) @@ -168,9 +168,13 @@ export function fleetExecutionPlan(config) { stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, - stages: config.resumeStage === 'DEPARTED_BEFORE_COSTS' - ? ['Verify reviewed report SHA256', 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', - 'Create fresh bounded cost/PDA sessions', 'Original trip operating costs with actual allocation availability', + stages: config.resumeStage?.startsWith('DEPARTED_') + ? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' + ? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries' + : 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', + config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions', + config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability', + 'Retain original DEPART; no picking or departure commands', 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', @@ -183,19 +187,26 @@ export function fleetExecutionPlan(config) { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) - if (stage === 'DEPARTED_BEFORE_COSTS') { + if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER') { + const afterCosts = stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false) - if (report?.success !== false || proof?.stage !== 'TRIP_OPERATING_COSTS' || proof.status !== 'CHECKING' + const failureName = afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘' + const handover = trip?.handover + if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING' || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash - || costs.status !== 'FAILED' || !Array.isArray(costs.costs) || costs.costs.length || !costs.requests?.length - || costs.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') - || trip.handover || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch + || costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length + || costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId + || (afterCosts ? ![200, 403].includes(row.http) || !['200', 'A0403', 'A0409'].includes(id(row.code)) : row.method !== 'GET' || row.http !== 200 || id(row.code) !== '200')) + || (afterCosts ? handover?.status !== 'FAILED' || !handover.requests?.length || handover.confirmRequest || handover.selection + || handover.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') + || !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover) + || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch || resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId - || failures?.length !== 2 || !failures.some(row => row.name === '独立预期覆盖全部实物且没有重叠托盘') - || failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].includes(row.name))) + || failures?.length !== 2 || !failures.some(row => row.name === failureName) + || failures.some(row => ![failureName, '模块执行'].includes(row.name))) throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes') - return { resources, marker: proof.marker, departed: true } + return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts } } if (stage === 'TERMINAL_READONLY') { const failures = report?.steps?.filter(row => row.passed === false) @@ -298,7 +309,7 @@ export async function verifyFleetTerminalState(context) { } /** All business checks happen before creating replacement test sessions or issuing a business command. */ -export async function verifyDepartedRecovery(context) { +export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false } = {}) { const proof = context.createdResources.fleetAcceptance const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad @@ -324,7 +335,8 @@ export async function verifyDepartedRecovery(context) { && departures?.length === 1 && same(departures[0], original) && history.records.every(row => BigInt(row.recordVersion) <= BigInt(original.recordVersion)), 'immutable DEPART/history changed') const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`) - check(costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0 + check((costsAlreadyVerified ? same(costs, trip.costAcceptance.final) + : costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0) && costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed') check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) @@ -638,6 +650,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol } export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, + costsAlreadyVerified = false, verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId @@ -646,8 +659,10 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) } const check = (label, ok) => { if (!ok) throw Error(label) } async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data } - stage('TRIP_OPERATING_COSTS') - await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + if (!costsAlreadyVerified) { + stage('TRIP_OPERATING_COSTS') + await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + } else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED') stage('IN_TRANSIT_HANDOVER') await handover(context, trip, picker.token, dispatchDetails, { onReady: onHandoverReady ? async fixture => { @@ -681,31 +696,39 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa } export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, + costsAlreadyVerified = false, resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, complete = completeDepartedFleetScenario } = {}) { - const verified = await verify(context) + const verified = await verify(context, { costsAlreadyVerified }) const proof = context.createdResources.fleetAcceptance, trip = verified.trip - context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors - trip.previousReadOnlyCostAttempts = [trip.costAcceptance] + if (!costsAlreadyVerified) { + context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors + trip.previousReadOnlyCostAttempts = [trip.costAcceptance] + delete context.createdResources.fleetCostActors + delete trip.costAcceptance + } else { + trip.previousReadOnlyHandoverAttempts = [trip.handover] + delete trip.handover + } proof.previousPicker = proof.picker - delete context.createdResources.fleetCostActors - delete trip.costAcceptance delete proof.picker proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}` await context.checkpoint() let actors, picker try { - actors = await prepareActors(context) - if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken + if (!costsAlreadyVerified) { + actors = await prepareActors(context) + if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken || actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token)) - throw Error('Recovery requires fresh independent cost sessions') + throw Error('Recovery requires fresh independent cost sessions') + } const pickerContext = Object.create(context); pickerContext.marker = proof.pickerMarker const access = await resolveAccess(context) picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session') proof.picker = { membershipId: picker.membershipId, username: picker.username } await context.checkpoint() - return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors }) + return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified }) } finally { try { await closePicker(context, picker, 71) } finally { await actors?.close() } } @@ -738,7 +761,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous) if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports') const bytes = readFileSync(previous) - if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) + if (config.resumeStage?.startsWith('DEPARTED_') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) throw Error('Reviewed departure report SHA256 mismatch; no environment request sent') resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage) save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage }) @@ -774,7 +797,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c } context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) const fleetScenario = async (ctx, options = {}) => { - if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors }) + if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, costsAlreadyVerified: options.costsAlreadyVerified }) const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) try { return await scenario(ctx, { ...options, costActors }) } finally { await costActors?.close() } @@ -801,6 +824,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, resumeDeparted: resumed.departed, + costsAlreadyVerified: resumed.costsAlreadyVerified, resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index fc46aeb5f..0808a4ac8 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -15,7 +15,7 @@ function departedRecoveryFixture() { dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' }, departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } } const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure, - requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] }, + requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't' }] }, externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } } const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }], createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } } @@ -38,6 +38,46 @@ function departedRecoveryFixture() { return { report, ctx, calls, responses, base, trip } } +function afterCostsRecoveryFixture() { + const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture + report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER' + report.steps[0].name = '每张排车已发车且货量与客户起运仓一致' + trip.costAcceptance.status = 'VERIFIED' + trip.costAcceptance.costs = [{ costNo: 'CNY-COST' }, { costNo: 'USD-COST' }] + trip.costAcceptance.final = { page: { records: structuredClone(trip.costAcceptance.costs), total: 2 }, + summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [{ currency: 'CNY', directCost: '100.03' }, { currency: 'USD', directCost: '13.07' }], allocationBlockReason: 'MULTI_STOP' } } + trip.handover = { status: 'FAILED', requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] } + responses.set(base + '/costs?page=1&size=100', structuredClone(trip.costAcceptance.final)) + return fixture +} + +test('after-cost recovery requires its exact known read-only handover failure and rejects any handover write', () => { + const { report } = afterCostsRecoveryFixture() + assert.equal(reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER').costsAlreadyVerified, true) + report.createdResources.fleetTrips[0].handover.requests.push({ method: 'POST', status: 'RESPONDED', http: 200, code: '200' }) + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/) +}) + +test('after-cost recovery checks original cost contents and refuses a changed amount before any write', async () => { + const { ctx, responses, base } = afterCostsRecoveryFixture() + responses.get(base + '/costs?page=1&size=100').summary.totals[0].directCost = '100.04' + await assert.rejects(continueReviewedDeparture(ctx, { costsAlreadyVerified: true, + prepareActors: () => assert.fail('no new actors'), makePicker: () => assert.fail('no new PDA before verification') }), /costs already written or cargo basis changed/) +}) + +test('after-cost recovery retains verified costs and only enters handover, signatures and resource closure', async () => { + const { ctx, trip } = afterCostsRecoveryFixture(), costs = trip.costAcceptance, oldHandover = trip.handover, operations = [] + await continueReviewedDeparture(ctx, { costsAlreadyVerified: true, prepareActors: () => assert.fail('verified costs need no new cost actors'), + resolveAccess: async () => ({}), makePicker: async () => ({ token: 'new-pda', membershipId: 'NEW-PDA' }), closePicker: async () => operations.push('close-pda'), + complete: (context, trip, picker, details, options) => completeDepartedFleetScenario(context, trip, picker, details, { ...options, + verifyCosts: () => assert.fail('costs must never be recreated'), handover: async () => operations.push('handover'), + completeDelivery: async (_ctx, order) => { operations.push('sign:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } }, + closeTrip: async () => { operations.push('release'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, cleanup: async () => operations.push('cleanup'), + }) }) + assert.equal(trip.costAcceptance, costs); assert.equal(trip.previousReadOnlyHandoverAttempts[0], oldHandover) + assert.deepEqual(operations, ['handover', 'sign:DL-OWN', 'sign:DL-EXT', 'release', 'cleanup', 'close-pda']) +}) + test('departed recovery requires reviewed SHA and exactly the original read-only cost failure', () => { assert.throws(() => parseFleetArguments(['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS']), /SHA256/) const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)] -- 2.54.0 From 99a469e6cf7ea89658dc5dcfd5584a43028c9224 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 20:36:09 +0800 Subject: [PATCH 119/176] fix(fleet): retain unavailable source cost shares for multi-stop trips --- .../fleet-quotes-operations.mjs | 12 +++++++++++- .../fleet-operations-test-env-contract.test.mjs | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index 640cb3ec6..694f8a10c 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -12,9 +12,18 @@ export function operationsFixtureFromHandover(bytes, { sha256, marker, runId }) || f.departure?.tripNo !== f.tripNo || f.departure.status !== 'IN_TRANSIT' || !Number.isFinite(Date.parse(f.departure.departedAt)) || snapshot.trip.departedAt !== f.departure.departedAt || !load?.manifestHash || c?.status !== 'VERIFIED' || c.marker !== marker || c.tripNo !== f.tripNo - || c.expected?.finalCny !== '100.03' || c.expected?.usd !== '13.07' || c.expected?.allocationMethod !== 'SAME_ROUTE_PALLET_SPACES') fail() + || c.expected?.finalCny !== '100.03' || c.expected?.usd !== '13.07') fail() const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0 if (!Array.isArray(load.pallets) || !load.pallets.length || !Array.isArray(load.cargo) || !load.cargo.length || !Array.isArray(c.shares) || !c.shares.length) fail() + const stops = snapshot.trip.stops + if (!Array.isArray(stops) || load.pallets.some(p => !Number.isSafeInteger(p.deliveryIndex) || p.deliveryIndex <= 0 + || p.deliveryIndex >= stops.length || !stops[p.deliveryIndex]?.destinationKey + || !load.cargo.some(row => String(row.warehousePalletId) === String(p.warehousePalletId)) + || load.cargo.filter(row => String(row.warehousePalletId) === String(p.warehousePalletId)).some(row => row.destinationKey !== stops[p.deliveryIndex].destinationKey))) fail() + const sameRoute = new Set(load.pallets.map(p => p.deliveryIndex)).size === 1 + if (sameRoute ? c.expected.allocationMethod !== 'SAME_ROUTE_PALLET_SPACES' || c.expected.allocationStatus !== 'AVAILABLE' + : c.expected.allocationMethod !== null || c.expected.allocationStatus !== 'UNAVAILABLE' + || c.expected.allocationBlockReason !== '各票卸货站不同,暂不可按同路线托位自动分摊') fail() const pallets = new Map() for (const p of load.pallets) { if (!p.warehousePalletId || pallets.has(String(p.warehousePalletId)) || !positive(p.palletSpaces)) fail(); pallets.set(String(p.warehousePalletId), Number(p.palletSpaces)) } const seen = new Set(), shipped = new Map() @@ -53,6 +62,7 @@ export function operationsFixtureFromHandover(bytes, { sha256, marker, runId }) const total = [...shipped.values()].reduce((a, b) => a + b, 0), ext = [...external.values()].reduce((a, b) => a + b, 0) if (total !== Number(f.departure.shippedPackages) || ext <= 0 || ext >= total) fail() const costs = [['CNY', '100.03'], ['USD', '13.07']].map(([currency, amount]) => { + if (!sameRoute) return { currency, directCost: Number(amount), ownCost: null, externalCost: null } const allocations = expectedAllocations(amount, c.shares) const sum = type => Number(allocations.filter(row => row.sourceType === type).reduce((n, row) => n + BigInt(row.cents), 0n)) / 100 return { currency, directCost: Number(amount), ownCost: sum('OWN'), externalCost: sum('EXTERNAL') } diff --git a/scripts/fleet-operations-test-env-contract.test.mjs b/scripts/fleet-operations-test-env-contract.test.mjs index 53858bf45..d8d8aa68f 100644 --- a/scripts/fleet-operations-test-env-contract.test.mjs +++ b/scripts/fleet-operations-test-env-contract.test.mjs @@ -10,11 +10,11 @@ function sample() { return { marker, runId: 'current-run', tripNo, warehouseId: '92', status: 'DEPART_AND_HANDOVER_VERIFIED', handoverNo: 'HO-CURRENT', departure: { tripNo, status: 'IN_TRANSIT', departedAt, shippedPackages: 14 }, immutableDeparture: { action: 'DEPART', recordVersion: '5', snapshot: { - trip: { tripNo, warehouseId: '92', departedAt, remark: marker }, confirmedLoad: { manifestHash: 'original-load', - pallets: [{ warehousePalletId: '1', palletSpaces: 1 }, { warehousePalletId: '2', palletSpaces: 1 }], - cargo: [{ dispatchNo: 'DP-OWN', dispatchItemId: '1', customerId: '9', warehousePalletId: '1', packages: 10 }, { dispatchNo: 'DP-EXT', dispatchItemId: '2', customerId: '9', warehousePalletId: '2', packages: 4 }] }, + trip: { tripNo, warehouseId: '92', departedAt, remark: marker, stops: [{ name: 'origin' }, { destinationKey: 'FBA:A' }, { destinationKey: 'FBA:B' }] }, confirmedLoad: { manifestHash: 'original-load', + pallets: [{ warehousePalletId: '1', palletSpaces: 1, deliveryIndex: 1 }, { warehousePalletId: '2', palletSpaces: 1, deliveryIndex: 1 }], + cargo: [{ dispatchNo: 'DP-OWN', dispatchItemId: '1', customerId: '9', warehousePalletId: '1', packages: 10, destinationKey: 'FBA:A' }, { dispatchNo: 'DP-EXT', dispatchItemId: '2', customerId: '9', warehousePalletId: '2', packages: 4, destinationKey: 'FBA:A' }] }, externalBookings: [{ bookingNo: 'FB-EXT', status: 'BOUND', dispatchNo: 'DP-EXT', customerId: '9', agreedAmount: '30.00', cargo: { currency: 'CNY' }, receipt: { source: { receivedPackages: 4, pallets: [{ warehousePalletId: '2', packages: 4 }] } } }] } }, - costs: { marker, tripNo, status: 'VERIFIED', expected: { finalCny: '100.03', usd: '13.07', allocationMethod: 'SAME_ROUTE_PALLET_SPACES' }, shares: [ + costs: { marker, tripNo, status: 'VERIFIED', expected: { finalCny: '100.03', usd: '13.07', allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationStatus: 'AVAILABLE' }, shares: [ { businessNo: 'DP-OWN', sourceType: 'OWN', dispatchNo: 'DP-OWN', palletSpaces: 1, warehousePalletIds: ['1'] }, { businessNo: 'FB-EXT', sourceType: 'EXTERNAL', dispatchNo: 'DP-EXT', palletSpaces: 1, warehousePalletIds: ['2'] }] } } } @@ -30,6 +30,14 @@ test('adapts actual C artifact with independent physical cargo and exact cent cu { currency: 'USD', directCost: 13.07, ownCost: 6.54, externalCost: 6.53 }] }) assert.equal(f.stage, 'CURRENT_RUN_DEPARTED'); assert.equal(f.departureVerified, true) }) +test('multi-stop costs retain full trip amounts and never infer source shares', () => { + const f = sample(), load = f.immutableDeparture.snapshot.confirmedLoad + load.pallets[1].deliveryIndex = 2; load.cargo[1].destinationKey = 'FBA:B' + Object.assign(f.costs.expected, { allocationMethod: null, allocationStatus: 'UNAVAILABLE', allocationBlockReason: '各票卸货站不同,暂不可按同路线托位自动分摊' }) + assert.deepEqual(convert(f).expected.costs, [{ currency: 'CNY', directCost: 100.03, ownCost: null, externalCost: null }, { currency: 'USD', directCost: 13.07, ownCost: null, externalCost: null }]) + f.costs.expected.allocationMethod = 'SAME_ROUTE_PALLET_SPACES' + assert.throws(() => convert(f)) +}) for (const [name, mutate] of [ ['stale marker', f => { f.marker = 'OLD' }], ['stale run', f => { f.runId = 'old' }], ['unverified costs', f => { f.costs.status = 'CHECKING' }], ['wrong currency amount', f => { f.costs.expected.usd = '0' }], -- 2.54.0 From 18b39b4378ceac8d28a2534bc4d9fc276dd6a246 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 21:00:07 +0800 Subject: [PATCH 120/176] fix(fleet): reuse reviewed handover vehicle and check formal responses --- scripts/README.md | 2 + ...leet-handover-acceptance-contract.test.mjs | 84 +++++++++++++++---- .../fleet-handover-acceptance.mjs | 59 ++++++++++--- scripts/fleet-test-env-acceptance.mjs | 82 +++++++++++++----- scripts/fleet-test-env-acceptance.test.mjs | 47 ++++++++++- 5 files changed, 226 insertions(+), 48 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index f5c0d7541..2c04aaf65 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -42,6 +42,8 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ 经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。 +`DEPARTED_VEHICLE_CREATED` 是独立的显式恢复阶段:只接受已验证成本、唯一接替车辆创建 POST 明确成功、随后仅因车牌大小写断言停止的报告。执行前核对精确报告 SHA、原成功响应、车辆完整当前快照,并通过 Jenkins 一致性只读查询确认该车辆在 `ll_fleet_resource_assignment` 中没有占用;缺少证据、车辆变更或占用均停止。已创建车辆显式传给交接助手,跳过原车辆创建 POST,从新司机及证照继续;原成本和 DEPART 保留。该成功写入不能借用只读失败恢复阶段忽略,也不得自动重发。 + `order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="" --env-file=""` 提供,数据库凭据只在执行节点内存读取。 diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs index 60e866482..f3e99b6f4 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -14,13 +14,23 @@ function fixture(fault = '') { const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] })) - const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) - const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' })) + if (fault === 'r5-layout') { + Object.assign(dispatchDetails[0], { packageCount: 5, pickedPackageCount: 5, items: [ + { id: 'ITEM-A1', warehousePalletId: 'PALLET-1', packageCount: 1 }, { id: 'ITEM-A2', warehousePalletId: 'PALLET-2', packageCount: 4 }] }) + Object.assign(dispatchDetails[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] }) + } + const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: row.packageCount })) + const cargo = dispatchDetails.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, + dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount, + destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' }))) + const pallets = [...new Set(cargo.map(row => row.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1 })) const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, - pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } } + pallets } } } const resources = new Map([['VEHICLE:11', 'TR-ONE'], ['DRIVER:21', 'TR-ONE']]) const vehicles = new Map(), drivers = new Map(), probes = new Map(), calls = [] - let createdVehicles = 0, handover = null, preview = null + let createdVehicles = fault === 'reviewed-vehicle' ? 1 : 0, handover = null, preview = null + if (fault === 'reviewed-vehicle') vehicles.set('12', { id: '12', plateNumber: 'HO-24255909-3BC', recordVersion: '0', + status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }) const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' }) const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) } @@ -30,11 +40,12 @@ function fixture(fault = '') { const context = { context: { async checkpointFleetHandover() {} }, marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, - departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: 20, dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: 10 })) } } }, + departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: dispatchDetails.reduce((n,row)=>n+row.packageCount,0), dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: row.packageCount })) } } }, assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) }, async requestMultipart(method, path, file) { calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0) - drivers.get('22').recordVersion = '1'; return ok({ id: '22', recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' }) + Object.assign(drivers.get('22'), { recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' }); + return ok(fault === 'upload-identity' ? { ...drivers.get('22'), id: 'OTHER' } : drivers.get('22')) }, async request(method, path, body, token) { calls.push({ method, path, body: copy(body), token }) @@ -47,12 +58,13 @@ function fixture(fault = '') { const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1) const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport, - deliveries: [{ deliveryNo: `DL-${customer}`, status: 'WAIT_DELIVERY', packages: 10, terminalAt: null, - transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } }], + deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', packages: row.packages, terminalAt: null, + transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } })), latestHandover: handover ? { handoverNo: handover.handoverNo } : null }) } if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1))) if (path.endsWith('/master-data/vehicles') && method === 'POST') { + assert.equal(body.plateNumber, body.plateNumber.toUpperCase(), 'request must use the formal uppercase vehicle plate') const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle) } if (path.includes('/master-data/vehicles/')) { @@ -61,13 +73,18 @@ function fixture(fault = '') { return ok(vehicle) } if (path.endsWith('/fleet/drivers') && method === 'POST') { - const driver = { id: '22', recordVersion: '0', details: body, active: true }; drivers.set(driver.id, driver); return ok(driver) + const profile = fault === 'driver-default-null' ? { warehouseId: null, vehicleModels: null, languagesAndArea: null, + medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } : {} + const driver = { id: '22', driverNo: 'DR-TEST', recordVersion: '0', details: { ...body, profile, + ...(fault === 'driver-default-null' ? { licenseIssuedOn: null } : {}) }, active: true, reviewStatus: 'PENDING' }; drivers.set(driver.id, driver); + if (fault === 'driver-default-nonempty') driver.details.profile.warehouseId = 'OTHER' + return ok(fault === 'driver-identity' ? { ...driver, recordVersion: '3' } : driver) } if (path.includes('/fleet/drivers/')) { const driver = drivers.get('22') if (path.endsWith('/review')) { driver.reviewStatus = 'APPROVED'; driver.recordVersion = '2' } if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' } - return ok(driver) + return ok(fault === 'review-version' && path.endsWith('/review') ? { ...driver, recordVersion: '1' } : driver) } if (path.endsWith('/fleet/trips') && method === 'POST') { const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] }) @@ -93,9 +110,13 @@ function fixture(fault = '') { return ok({ records, total: records.length }) } if (path.endsWith('/handovers/preview')) { - preview = { cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId, - palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: ['PALLET-INTERNAL'], occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 } } } + preview = { tripNo: trip.tripNo, recordVersion: trip.recordVersion, + previous: { vehicleId: '11', driverId: '21', ...originalTransport }, replacement: { vehicleId: '12', driverId: '22' }, + capacity: { palletSpaces: 30, weightKg: 18000, volumeCbm: 90 }, cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId, + palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: pallets.map(row=>row.warehousePalletId), occupied: { palletSpaces: pallets.length, weightKg: pallets.length*100, volumeCbm: pallets.length } } } if (fault === 'wrong-customer') preview.cargo.remainingCargo[0].customerId = 'OTHER' + if (fault === 'preview-identity') preview.replacement.driverId = 'OTHER' + if (fault === 'preview-capacity') preview.capacity.palletSpaces = 29 if (fault === 'double-count-pallet') preview.cargo.occupied.palletSpaces = 2 return ok(preview) } @@ -120,8 +141,10 @@ function fixture(fault = '') { return ok(handover) } assert.equal(String(body.recordVersion), handover.recordVersion) - resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location } - handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump(); return ok(handover) + resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location, + idempotencyKey: body.idempotencyKey, vehicleStatus: body.vehicleStatus } + handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump(); + return ok(fault === 'release-version' && vehicle ? { ...handover, recordVersion: '99' } : handover) } if (path.endsWith('/trips/TR-ONE') && method === 'GET') return ok({ trip, dispatches: bindings }) throw new Error(`Unexpected test route ${method} ${path}`) @@ -210,7 +233,9 @@ test('every HTTP call observes a completed pre-send checkpoint; post-response di assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING') }) -for (const [fault, message] of Object.entries({ 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', +for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接替司机返回身份和版本', 'driver-identity': '接替司机返回身份和版本', 'upload-identity': '测试证照有正式文件归属', + 'review-version': '接替司机已正式审核', 'preview-identity': '交接预览绑定原车次', 'preview-capacity': '交接预览容量', 'release-version': '原车辆已结束', + 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { @@ -247,8 +272,35 @@ test('explicit closure cleanup cancels only test probes and disables newly creat assert.ok(!f.calls.some(row => row.method === 'DELETE')) }) +test('formal driver response accepts empty profile with omitted or NULL optional fields', async () => { + const f = fixture('driver-default-null') + assert.equal((await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)).status, 'VERIFIED') +}) + +test('r5 two dispatches, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => { + const f = fixture('r5-layout') + const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) + assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaAfter.flatMap(row=>row.deliveries).length, 3) + assert.equal(result.preview.cargo.remainingPalletIds.length, 3) + f.proof.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } + f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() } + f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22') + assert.equal((await cleanupFleetHandoverResources(f.context, 'test', f.proof)).status, 'COMPLETED') +}) + +test('explicit reviewed vehicle reuse skips the original POST and proceeds from driver creation', async () => { + const f = fixture('reviewed-vehicle'), snapshot = copy(f.vehicles.get('12')) + const reviewedVehicle = { snapshot, occupation: { vehicleId: '12', assignmentCount: 0 }, + creationRequest: { method: 'POST', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't', + body: { plateNumber: 'HO-24255909-3bc' }, result: snapshot } } + const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails, { reviewedVehicle }) + assert.equal(result.status, 'VERIFIED'); assert.equal(result.replacement.vehicleId, '12') + const created = f.calls.filter(row=>row.method==='POST' && row.path?.endsWith('/master-data/vehicles')) + assert.equal(created.length, 1); assert.match(created[0].body.plateNumber, /^HO-PROBE-/) +}) + test('internal helper has no environment loader, SQL or standalone execution path', async () => { const source = await readFile(new URL('./fleet-handover-acceptance.mjs', import.meta.url), 'utf8') assert.doesNotMatch(source, /process\.env|process\.argv|fetch\(|execFile|INSERT INTO|UPDATE ll_|DELETE FROM/) - assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails\)/) + assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails, \{ reviewedVehicle \} = \{\}\)/) }) diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs index b9817746c..b83e0b1eb 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -80,7 +80,7 @@ function thousandths(value) { } /** Internal fresh-run helper, called only after the caller's real departure + replay and before signing. */ -export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails) { +export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails, { reviewedVehicle } = {}) { const run = requireRun(context, tripProof) if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length) throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑') @@ -135,28 +135,53 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke const capacity = { maxPalletSpaces: detail.trip.maxPalletSpaces, maxLoadKg: detail.trip.maxLoadKg, maxVolumeCbm: detail.trip.maxVolumeCbm } check('使用正式原车辆容量及有效任务结束时间', Object.values(capacity).every(value => Number(value) > 0 && Number.isFinite(Number(value))) && Date.parse(detail.trip.expectedDriverEndAt) > Date.now()) - const marker = `HO-${randomUUID().slice(0, 12)}` + const marker = reviewedVehicle ? reviewedVehicle.snapshot.plateNumber : `HO-${randomUUID().slice(0, 12).toUpperCase()}` const vehicleBody = { plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', ...capacity, remark: `${context.marker} controlled TEST handover vehicle` } proof.replacement = { marker, vehicleRequest: vehicleBody } - const vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆') + let vehicle + if (reviewedVehicle) { + check('显式沿用车辆具有已审阅成功请求和无占用证明', reviewedVehicle.creationRequest?.status === 'RESPONDED' + && reviewedVehicle.creationRequest.http === 200 && text(reviewedVehicle.creationRequest.code) === '200' + && reviewedVehicle.creationRequest.requestId && reviewedVehicle.creationRequest.traceId + && text(reviewedVehicle.occupation?.vehicleId) === text(reviewedVehicle.snapshot.id) && reviewedVehicle.occupation.assignmentCount === 0) + vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(reviewedVehicle.snapshot.id)}`, undefined, '再次核对已创建接替车辆完整快照') + check('已创建车辆未发生身份版本容量或状态变更', same(vehicle, reviewedVehicle.snapshot) + && text(vehicle.recordVersion) === '0' && vehicle.status === 'IN_SERVICE' + && vehicle.region === 'OVERSEAS' && vehicle.ownership === 'OWNED' + && Number(vehicle.maxPalletSpaces) === capacity.maxPalletSpaces && Number(vehicle.maxLoadKg) === capacity.maxLoadKg && Number(vehicle.maxVolumeCbm) === capacity.maxVolumeCbm) + proof.replacement.vehicleReuse = clone(reviewedVehicle) + } else vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆') check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker) proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber - const driver = await request('POST', `${FLEET}/drivers`, { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + const driverBody = { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), - remark: `${context.marker} TEST credential fixture; not a real driving licence` }, '建立本轮接替司机档案') - check('接替司机返回身份和版本', driver?.id && validVersion(driver.recordVersion)) + remark: `${context.marker} TEST credential fixture; not a real driving licence` } + const driver = await request('POST', `${FLEET}/drivers`, driverBody, '建立本轮接替司机档案') + const profileFields = ['warehouseId', 'vehicleModels', 'languagesAndArea', 'medicalExpiresOn', 'trainingExpiresOn', 'insuranceExpiresOn'] + check('接替司机返回身份和版本', driver?.id && text(driver.recordVersion) === '0' && driver.active === true + && Object.entries(driverBody).every(([field, value]) => driver.details?.[field] === value) + && driver.details.licenseIssuedOn == null && driver.details.profile && !Array.isArray(driver.details.profile) + && profileFields.every(field => driver.details.profile[field] == null)) proof.replacement.driverId = text(driver.id) const file = { name: `${marker}-TEST-NOT-REAL-LICENSE.png`, type: 'image/png', content: certificate } proof.replacement.certificate = { name: file.name, type: file.type, contentBase64: certificate.toString('base64'), sha256: createHash('sha256').update(certificate).digest('hex'), synthetic: true } const uploaded = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/license?recordVersion=${driver.recordVersion}`, undefined, '正式上传非真实测试证照', { file }) - check('测试证照有正式文件归属', uploaded?.licenseFileId && validVersion(uploaded.recordVersion)) + check('测试证照有正式文件归属', text(uploaded?.id) === text(driver.id) && uploaded?.licenseFileId + && text(uploaded.recordVersion) === String(BigInt(driver.recordVersion) + 1n)) proof.replacement.licenseFileId = text(uploaded.licenseFileId) const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机') - check('接替司机已正式审核', reviewed?.reviewStatus === 'APPROVED') + check('接替司机已正式审核', text(reviewed?.id) === text(driver.id) && reviewed.reviewStatus === 'APPROVED' + && text(reviewed.recordVersion) === String(BigInt(uploaded.recordVersion) + 1n) && reviewed.licenseFileId === uploaded.licenseFileId) proof.selection = { recordVersion: detail.trip.recordVersion, vehicleId: vehicle.id, driverId: driver.id, expectedDriverEndAt: detail.trip.expectedDriverEndAt, handedOverAt: new Date().toISOString() } const preview = await request('POST', `${base}/handovers/preview`, proof.selection, '预览真实剩余货物及资源资格') + check('交接预览绑定原车次版本和确切新旧身份', preview?.tripNo === tripProof.tripNo + && text(preview.recordVersion) === text(proof.selection.recordVersion) + && text(preview.previous?.vehicleId) === proof.previous.vehicleId && text(preview.previous?.driverId) === proof.previous.driverId + && text(preview.replacement?.vehicleId) === text(vehicle.id) && text(preview.replacement?.driverId) === text(driver.id)) + check('交接预览容量与已核对车辆一致', Number(preview.capacity?.palletSpaces) === capacity.maxPalletSpaces + && Number(preview.capacity?.weightKg) === capacity.maxLoadKg && Number(preview.capacity?.volumeCbm) === capacity.maxVolumeCbm) const expectedCargo = original.confirmedLoad.cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId, palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })) check('预览精确包含全部未签收客户货物且资格有效', /^[0-9a-f]{64}$/.test(text(preview?.cargoHash)) && preview.driverQualification?.canDispatch === true @@ -171,7 +196,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke proof.preview = preview proof.confirmRequest = { selection: proof.selection, cargoHash: preview.cargoHash, location: `${context.marker}受控交接点`, reason: '本轮真实发运后整车资源交接', idempotencyKey: key() } const handover = await request('POST', `${base}/handovers`, proof.confirmRequest, '确认实际在途交接') - check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && validVersion(handover.recordVersion) + check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && text(handover.recordVersion) === '0' && text(handover.snapshot?.previous?.vehicleId) === proof.previous.vehicleId && text(handover.snapshot.previous.driverId) === proof.previous.driverId && text(handover.snapshot.replacement.vehicleId) === text(vehicle.id) && text(handover.snapshot.replacement.driverId) === text(driver.id) && same(inspectionFacts(handover.snapshot.cargo), inspectionFacts(preview.cargo)) && handover.vehicleRelease == null && handover.driverRelease == null) @@ -194,11 +219,21 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release` proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() } const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务') - check('原车辆已结束而原司机仍在任务中', vehicleReleased.vehicleRelease?.actualEndAt && vehicleReleased.driverRelease == null) + check('原车辆已结束而原司机仍在任务中', vehicleReleased.handoverNo === handover.handoverNo && vehicleReleased.tripNo === tripProof.tripNo + && text(vehicleReleased.recordVersion) === String(BigInt(proof.vehicleReleaseRequest.recordVersion) + 1n) + && Date.parse(vehicleReleased.vehicleRelease?.actualEndAt) === Date.parse(proof.vehicleReleaseRequest.actualEndAt) + && vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey + && vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE' + && vehicleReleased.driverRelease == null) await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ }) proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() } const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务') - check('原车辆司机分别保留结束事实', released.vehicleRelease?.actualEndAt && released.driverRelease?.actualEndAt) + check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo + && text(released.recordVersion) === String(BigInt(proof.driverReleaseRequest.recordVersion) + 1n) + && same(released.vehicleRelease, vehicleReleased.vehicleRelease) + && Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt) + && released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey + && released.driverRelease?.location === proof.driverReleaseRequest.location) const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划') firstProbe.recordVersion = assigned.trip.recordVersion check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId) @@ -208,7 +243,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot)) const secondProbe = await createProbe(2) await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ }) - const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10)}` } + const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` } const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆') check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id) await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ }) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 86068aa70..370dac2e4 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto' import { AsyncLocalStorage } from 'node:async_hooks' import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' -import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { createTestEvidenceReaders, buildEvidenceRemoteScript, validateEvidenceConfig } from './fba-core-regression-evidence.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs' @@ -154,7 +154,7 @@ export function parseFleetArguments(args) { } if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') - if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) throw Error('Departed recovery requires the exact reviewed report SHA256') if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') @@ -163,17 +163,19 @@ export function parseFleetArguments(args) { return config } export function fleetExecutionPlan(config) { + const afterCosts = ['DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage) return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover', returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full', stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, stages: config.resumeStage?.startsWith('DEPARTED_') - ? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' + ? ['Verify reviewed report SHA256', afterCosts ? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries' : 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', - config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions', - config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability', + ...(config.resumeStage === 'DEPARTED_VEHICLE_CREATED' ? ['Verify exact successful vehicle creation, complete current vehicle snapshot and zero resource assignments', 'Explicitly reuse the created vehicle; skip its creation POST and continue from new driver'] : []), + afterCosts ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions', + afterCosts ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability', 'Retain original DEPART; no picking or departure commands', 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', @@ -187,11 +189,20 @@ export function fleetExecutionPlan(config) { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) - if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER') { - const afterCosts = stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' + if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') { + const vehicleCreated = stage === 'DEPARTED_VEHICLE_CREATED' + const afterCosts = stage !== 'DEPARTED_BEFORE_COSTS' const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false) - const failureName = afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘' + const failureName = vehicleCreated ? '接替车辆返回真实身份及车牌' : afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘' const handover = trip?.handover + const creation = vehicleCreated ? handover?.requests?.filter(row => row.method !== 'GET') : null + if (vehicleCreated && (creation?.length !== 1 || creation[0].method !== 'POST' + || creation[0].path !== '/api/platform/v1/last-leg/master-data/vehicles' || creation[0].status !== 'RESPONDED' + || creation[0].http !== 200 || id(creation[0].code) !== '200' || !creation[0].requestId || !creation[0].traceId + || !/^[1-9]\d*$/.test(id(creation[0].result?.id)) || id(creation[0].result.recordVersion) !== '0' + || creation[0].result.plateNumber !== creation[0].body?.plateNumber?.toUpperCase() + || creation[0].result.status !== 'IN_SERVICE' || handover.replacement?.driverId)) + throw Error('Created vehicle recovery requires the exact definitive successful creation and no later handover write') if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING' || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash @@ -199,14 +210,15 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId || (afterCosts ? ![200, 403].includes(row.http) || !['200', 'A0403', 'A0409'].includes(id(row.code)) : row.method !== 'GET' || row.http !== 200 || id(row.code) !== '200')) || (afterCosts ? handover?.status !== 'FAILED' || !handover.requests?.length || handover.confirmRequest || handover.selection - || handover.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') + || handover.requests.some(row => (row.method !== 'GET' && row !== creation?.[0]) || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') || !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover) || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch || resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId || failures?.length !== 2 || !failures.some(row => row.name === failureName) || failures.some(row => ![failureName, '模块执行'].includes(row.name))) throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes') - return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts } + return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts, + createdVehicle: vehicleCreated ? { snapshot: creation[0].result, creationRequest: creation[0] } : null } } if (stage === 'TERMINAL_READONLY') { const failures = report?.steps?.filter(row => row.passed === false) @@ -309,7 +321,7 @@ export async function verifyFleetTerminalState(context) { } /** All business checks happen before creating replacement test sessions or issuing a business command. */ -export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false } = {}) { +export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle } = {}) { const proof = context.createdResources.fleetAcceptance const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad @@ -363,14 +375,42 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f evidence.deliveries.push(order) } check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch') + if (createdVehicle) { + const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`) + const canonical = value => value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value + check(same(canonical(vehicle), canonical(createdVehicle.snapshot)), 'created vehicle snapshot changed') + check(typeof context.context?.readReviewedVehicleOccupation === 'function', 'vehicle occupancy reader missing') + const occupation = await context.context.readReviewedVehicleOccupation(vehicle.id) + check(id(occupation?.vehicleId) === id(vehicle.id) && occupation.assignmentCount === 0, 'created vehicle is occupied') + evidence.reviewedVehicle = { ...createdVehicle, snapshot: vehicle, occupation } + } Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash, departure: departures[0], costSummary: costs.summary }) await context.checkpoint() - return { trip, dispatchDetails: evidence.dispatchDetails } + return { trip, dispatchDetails: evidence.dispatchDetails, reviewedVehicle: evidence.reviewedVehicle } } -export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) { - const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails) +export async function readReviewedVehicleOccupation(env, vehicleId, fetcher = globalThis.fetch) { + validateEvidenceConfig(env) + if (!/^[1-9]\d*$/.test(id(vehicleId))) throw Error('Exact numeric reviewed vehicle ID required') + const sql = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', + `SELECT JSON_OBJECT('vehicleId','${vehicleId}','assignmentCount',COUNT(*)) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=${vehicleId};`, 'COMMIT;'].join('\n') + const script = buildEvidenceRemoteScript('lastleg', sql, env.FBA_CORE_JENKINS_AGENT) + const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', + headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) }) + if (!response.ok) throw Error('Reviewed vehicle occupancy HTTP failure') + const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON=')) + if (lines.length !== 1) throw Error('Reviewed vehicle occupancy response ambiguous') + const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length)) + if (result.ok !== true || result.rows?.length !== 1) throw Error('Reviewed vehicle occupancy query failed') + return result.rows[0] +} + +export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) { + const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle }) const run = context.createdResources.fleetAcceptance if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo || run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser @@ -650,7 +690,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol } export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, - costsAlreadyVerified = false, + costsAlreadyVerified = false, reviewedVehicle, verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId @@ -665,6 +705,7 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa } else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED') stage('IN_TRANSIT_HANDOVER') await handover(context, trip, picker.token, dispatchDetails, { + reviewedVehicle, onReady: onHandoverReady ? async fixture => { stage('PDA_UI_CHECKPOINT') await onHandoverReady({ fixture, picker }) @@ -696,10 +737,10 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa } export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, - costsAlreadyVerified = false, + costsAlreadyVerified = false, createdVehicle, resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, complete = completeDepartedFleetScenario } = {}) { - const verified = await verify(context, { costsAlreadyVerified }) + const verified = await verify(context, { costsAlreadyVerified, createdVehicle }) const proof = context.createdResources.fleetAcceptance, trip = verified.trip if (!costsAlreadyVerified) { context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors @@ -728,7 +769,7 @@ export async function continueReviewedDeparture(context, { verify = verifyDepart if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session') proof.picker = { membershipId: picker.membershipId, username: picker.username } await context.checkpoint() - return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified }) + return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified, reviewedVehicle: verified.reviewedVehicle }) } finally { try { await closePicker(context, picker, 71) } finally { await actors?.close() } } @@ -784,6 +825,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c }, { guardFetch: true }) context.context.fleetRunId = config.runId context.context.fleetScenario = config.scenario || 'handover' + context.context.readReviewedVehicleOccupation = vehicleId => readReviewedVehicleOccupation(env, vehicleId) context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture) let handoverCheckpoint = 0 @@ -797,7 +839,8 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c } context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) const fleetScenario = async (ctx, options = {}) => { - if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, costsAlreadyVerified: options.costsAlreadyVerified }) + if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, + costsAlreadyVerified: options.costsAlreadyVerified, createdVehicle: options.createdVehicle }) const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) try { return await scenario(ctx, { ...options, costActors }) } finally { await costActors?.close() } @@ -825,6 +868,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, resumeDeparted: resumed.departed, costsAlreadyVerified: resumed.costsAlreadyVerified, + createdVehicle: resumed.createdVehicle, resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 0808a4ac8..5dbb91355 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -38,6 +38,51 @@ function departedRecoveryFixture() { return { report, ctx, calls, responses, base, trip } } +function createdVehicleRecoveryFixture() { + const f = afterCostsRecoveryFixture(), snapshot = { id: '2106360046045675522', plateNumber: 'HO-24255909-3BC', recordVersion: '0', + status: 'IN_SERVICE', ownership: 'OWNED', region: 'OVERSEAS', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } + f.report.steps[0].name = '接替车辆返回真实身份及车牌' + const request = { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', body: { plateNumber: 'HO-24255909-3bc' }, + status: 'RESPONDED', http: 200, code: '200', requestId: 'created-r', traceId: 'created-t', result: snapshot } + f.trip.handover.requests.push(request) + f.responses.set('/api/platform/v1/last-leg/master-data/vehicles/' + snapshot.id, structuredClone(snapshot)) + f.ctx.context.readReviewedVehicleOccupation = async vehicleId => ({ vehicleId, assignmentCount: 0 }) + return { ...f, snapshot, request } +} + +test('created-vehicle checkpoint requires exact successful uppercase response and no subsequent writes', () => { + const { report } = createdVehicleRecoveryFixture() + const accepted = reviewedFullCheckpoint(report, 'DEPARTED_VEHICLE_CREATED') + assert.equal(accepted.createdVehicle.snapshot.plateNumber, 'HO-24255909-3BC') + assert.equal(accepted.createdVehicle.creationRequest.body.plateNumber, 'HO-24255909-3bc') + for (const mutation of [f => f.request.http = 0, f => f.request.status = 'UNKNOWN_RESULT', f => f.request.code = 'A0409', + f => f.request.traceId = null, f => f.trip.handover.requests.push({ method: 'POST', path: '/drivers' })]) { + const f = createdVehicleRecoveryFixture(); mutation(f) + assert.throws(() => reviewedFullCheckpoint(f.report, 'DEPARTED_VEHICLE_CREATED'), /Created vehicle recovery/) + } + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/) +}) + +for (const fault of ['none', 'changed', 'occupied', 'missing-reader']) test(`created vehicle exact snapshot and no occupation mandatory: ${fault}`, async () => { + const f = createdVehicleRecoveryFixture(), accepted = reviewedFullCheckpoint(f.report, 'DEPARTED_VEHICLE_CREATED') + if (fault === 'changed') f.responses.get('/api/platform/v1/last-leg/master-data/vehicles/' + f.snapshot.id).recordVersion = '1' + if (fault === 'occupied') f.ctx.context.readReviewedVehicleOccupation = async vehicleId => ({ vehicleId, assignmentCount: 1 }) + if (fault === 'missing-reader') delete f.ctx.context.readReviewedVehicleOccupation + const options = { costsAlreadyVerified: true, createdVehicle: accepted.createdVehicle } + if (fault === 'none') { const r = await verifyDepartedRecovery(f.ctx, options); assert.equal(r.reviewedVehicle.snapshot.id, f.snapshot.id) } + else await assert.rejects(continueReviewedDeparture(f.ctx, { ...options, makePicker: () => assert.fail('no session before verification') }), /recovery refused/) +}) + +test('occupancy reader is scoped to exact numeric vehicle and readonly evidence transport', async () => { + const env = { JENKINS_URL: 'https://test.invalid/', JENKINS_USER: 'reader', JENKINS_API_TOKEN: 'secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' } + const result = await readReviewedVehicleOccupation(env, '123', async (url, init) => { + assert.equal(url.pathname, '/scriptText'); assert.equal(init.method, 'POST'); assert.ok(init.body.get('script').includes('executeGroovy')) + return new Response('FBA_CORE_JSON={"ok":true,"rows":[{"vehicleId":"123","assignmentCount":0}]}') + }) + assert.deepEqual(result, { vehicleId: '123', assignmentCount: 0 }) + await assert.rejects(readReviewedVehicleOccupation(env, '123; DELETE', () => assert.fail('invalid identity')), /numeric/) +}) + function afterCostsRecoveryFixture() { const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER' @@ -156,7 +201,7 @@ test('changed source report SHA blocks recovery before creating context or loggi rmSync(temporary, { recursive: true, force: true }) } }) -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, -- 2.54.0 From 3987562890095ff19d5a9b5818594ee6568e3eff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 21:24:00 +0800 Subject: [PATCH 121/176] feat(test): support scoped Firstleg read-only receiver evidence --- scripts/fba-core-regression-evidence.mjs | 2 +- scripts/fba-core-regression-evidence.test.mjs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/fba-core-regression-evidence.mjs b/scripts/fba-core-regression-evidence.mjs index e6fa56c5d..24dddbb64 100644 --- a/scripts/fba-core-regression-evidence.mjs +++ b/scripts/fba-core-regression-evidence.mjs @@ -130,7 +130,7 @@ const base64Expression = value => '['+Buffer.from(value).toString('base64').matc // Credentials stay in the executor's memory. No deploy, SQL mutation, or arbitrary SQL entry point. export function buildEvidenceRemoteScript(service, sql, agent) { assertReadOnlySql(sql) - if (!['order','lastleg'].includes(service) || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent)) throw Error('Invalid TEST evidence target') + if (!['order','firstleg','lastleg'].includes(service) || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent)) throw Error('Invalid TEST evidence target') const prefix=service.toUpperCase(),container=`firstleg-test-${service}` const python = `import json,os,subprocess,urllib.parse,base64,re try: diff --git a/scripts/fba-core-regression-evidence.test.mjs b/scripts/fba-core-regression-evidence.test.mjs index 60fb68ec2..206ea130d 100644 --- a/scripts/fba-core-regression-evidence.test.mjs +++ b/scripts/fba-core-regression-evidence.test.mjs @@ -58,3 +58,17 @@ test('transport frames valid empty rows and suppresses raw remote and connection const script=buildEvidenceRemoteScript('order',buildCompletionEvidenceSql(completion()),'java-test-agent') assert.equal(script.includes(env().JENKINS_API_TOKEN),false) }) + +test('Firstleg receiver evidence stays on its exact TEST container and rejects writes', () => { + const sql = buildCompletionEvidenceSql(completion()) + const decode = value => Buffer.from([...value.matchAll(/'([A-Za-z0-9+/=]{40,})'/g)].map(m => m[1]).join(''), 'base64').toString('utf8') + const python = decode(decode(buildEvidenceRemoteScript('firstleg', sql, 'java-test-agent'))) + assert.match(python, /docker','inspect','firstleg-test-firstleg'/) + assert.match(python, /get\('Name'\)!='\/firstleg-test-firstleg'/) + assert.match(python, /com\.docker\.compose\.project'\)!='firstleg-test'/) + assert.match(python, /e\['FIRSTLEG_DB_URL'\]/) + assert.match(python, /e\['FIRSTLEG_DB_USERNAME'\]/) + assert.match(python, /MYSQL_PWD=e\['FIRSTLEG_DB_PASSWORD'\]/) + assert.throws(() => buildEvidenceRemoteScript('firstleg', sql.replace('COMMIT;', 'DELETE FROM fl_inbound_order;\nCOMMIT;'), 'java-test-agent')) + assert.throws(() => buildEvidenceRemoteScript('firstleg-prod', sql, 'java-test-agent')) +}) -- 2.54.0 From 48faf43e3702b649f2a2b85ed7563f96bb55387a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 21:26:46 +0800 Subject: [PATCH 122/176] fix(test): bind fleet statistics evidence without optional remarks --- scripts/admin-test-env-modules/fleet-quotes-operations.mjs | 4 ++-- scripts/fleet-operations-test-env-contract.test.mjs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index 694f8a10c..1e8d132e8 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -8,7 +8,7 @@ export function operationsFixtureFromHandover(bytes, { sha256, marker, runId }) const f = JSON.parse(bytes.toString('utf8')), history = f.immutableDeparture, snapshot = history?.snapshot, load = snapshot?.confirmedLoad, c = f.costs if (f.marker !== marker || f.runId !== runId || f.status !== 'DEPART_AND_HANDOVER_VERIFIED' || !f.handoverNo || !f.tripNo || !f.warehouseId || history?.action !== 'DEPART' || snapshot?.trip?.tripNo !== f.tripNo - || String(snapshot.trip.warehouseId) !== String(f.warehouseId) || !snapshot.trip.remark?.includes(marker) + || String(snapshot.trip.warehouseId) !== String(f.warehouseId) || f.departure?.tripNo !== f.tripNo || f.departure.status !== 'IN_TRANSIT' || !Number.isFinite(Date.parse(f.departure.departedAt)) || snapshot.trip.departedAt !== f.departure.departedAt || !load?.manifestHash || c?.status !== 'VERIFIED' || c.marker !== marker || c.tripNo !== f.tripNo @@ -214,7 +214,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames proof.tripNo = fixture.tripNo const trip = await command('GET', `/api/platform/v1/last-leg/fleet/trips/${encode(fixture.tripNo)}`, undefined, '核对本轮真实发车车次') check('车次属于当前场景并有真实发车时间', null, trip.trip?.tripNo === fixture.tripNo && id(trip.trip.warehouseId) === id(fixture.warehouseId) - && trip.trip.remark?.includes(context.marker) && Date.parse(trip.trip.departedAt) === Date.parse(fixture.departedAt) + && (fixture.immutableDeparture || trip.trip.remark?.includes(context.marker)) && Date.parse(trip.trip.departedAt) === Date.parse(fixture.departedAt) && ['IN_TRANSIT', 'COMPLETED'].includes(trip.trip.status)) from = new Date(Date.parse(fixture.departedAt) - 1).toISOString(); to = new Date(Date.parse(fixture.departedAt) + 1000).toISOString() const reportPath = `${OPERATIONS}?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo, page: 1, size: 20 })}` diff --git a/scripts/fleet-operations-test-env-contract.test.mjs b/scripts/fleet-operations-test-env-contract.test.mjs index d8d8aa68f..55f3942ad 100644 --- a/scripts/fleet-operations-test-env-contract.test.mjs +++ b/scripts/fleet-operations-test-env-contract.test.mjs @@ -38,6 +38,12 @@ test('multi-stop costs retain full trip amounts and never infer source shares', f.costs.expected.allocationMethod = 'SAME_ROUTE_PALLET_SPACES' assert.throws(() => convert(f)) }) +test('SHA-bound current artifact does not require optional trip remarks in the DEPART snapshot', () => { + const f = sample(); delete f.immutableDeparture.snapshot.trip.remark + assert.equal(convert(f).tripNo, f.tripNo) + f.costs.marker = 'OLD' + assert.throws(() => convert(f)) +}) for (const [name, mutate] of [ ['stale marker', f => { f.marker = 'OLD' }], ['stale run', f => { f.runId = 'old' }], ['unverified costs', f => { f.costs.status = 'CHECKING' }], ['wrong currency amount', f => { f.costs.expected.usd = '0' }], -- 2.54.0 From 6dbc899626b28ea05e7e9bec8b2faa3a35677577 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 21:54:07 +0800 Subject: [PATCH 123/176] fix(lastleg): resolve mixed-pallet return sources by dispatch snapshots --- .../service/ExceptionOrderService.java | 15 ++- ...xceptionOrderDispatchReturnSourceTest.java | 113 ++++++++++++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index d6da45553..3a0594bf1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -485,12 +485,17 @@ public class ExceptionOrderService { for (DispatchPdaPickOrder attribution : attributions) { LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId()); if (tail == null || !customerId.equals(tail.getCustomerId())) continue; - OutboundPalletItem outboundItem = outboundItemById.values().stream() + List matchingItems = outboundItemById.values().stream() .filter(item -> Objects.equals(item.getWarehousePalletId(), dispatchItem.getWarehousePalletId())) - .filter(item -> !StringUtils.hasText(item.getInboundCode()) - || Objects.equals(item.getInboundCode(), tail.getInboundCode())) - .findFirst().orElse(null); - if (outboundItem == null) continue; + .filter(item -> Objects.equals(item.getInboundCode(), dispatchItem.getInboundCode())) + .filter(item -> Objects.equals(item.getDestinationWarehouseId(), dispatchItem.getDestinationWarehouseId())) + .filter(item -> Objects.equals(item.getDestinationWarehouseCode(), dispatchItem.getDestinationWarehouseCode())) + .filter(item -> Objects.equals(item.getDestinationWarehouseName(), dispatchItem.getDestinationWarehouseName())) + .filter(item -> Objects.equals(item.getOrderRefs(), dispatchItem.getOrderRefs())).toList(); + if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源"); + if (matchingItems.isEmpty()) continue; + OutboundPalletItem outboundItem = matchingItems.get(0); + // A mixed pallet has one physical outbound item; each source is identified by the actual pick attribution. String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + tail.getInboundCode(); long packages = Objects.requireNonNullElse(attribution.getPackages(), 0L); aggregated.merge(key, new Source(outboundItem.getId(), pick.getInventoryPositionId(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java index 4e7ef7760..76b1eeaa5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java @@ -79,6 +79,7 @@ class ExceptionOrderDispatchReturnSourceTest { DispatchItem dispatchItem = new DispatchItem(); dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L); dispatchItem.setWarehousePalletId(501L); + dispatchItem.setInboundCode("IN-1"); DispatchPdaPick pick = new DispatchPdaPick(); pick.setId(203L); pick.setCustomerId("1001"); pick.setTaskId(201L); pick.setDispatchItemId(202L); pick.setInventoryPositionId(601L); pick.setPackages(2L); @@ -112,4 +113,116 @@ class ExceptionOrderDispatchReturnSourceTest { assertThat(source.maxReturnPackages()).isEqualTo(2L); }); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SINGLE", "MULTI", "MULTI_REVERSED", "AMBIGUOUS"}) + void mixedPalletRetainsSecondInboundSourceAndSumsOnlyItsActualPickAttributions(String layout) { + var metadata = new org.apache.ibatis.builder.MapperBuilderAssistant( + new com.baomidou.mybatisplus.core.MybatisConfiguration(), "mixed-return-source-test"); + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(metadata, LastlegOrder.class); + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(metadata, LastlegInboundOrder.class); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(400L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); + delivery.setOutboundNo("OUT-1"); delivery.setStatus("DELIVERED"); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(300L); outbound.setCustomerId("1001"); outbound.setOutboundNo("OUT-1"); + outbound.setDispatchOrderId(200L); + OutboundPalletItem outboundItem = new OutboundPalletItem(); + outboundItem.setId(301L); outboundItem.setCustomerId("1001"); outboundItem.setOutboundOrderId(300L); + outboundItem.setWarehousePalletId(501L); outboundItem.setPalletNo("PLT-1"); outboundItem.setInboundCode("IN-1"); + DispatchPdaTask task = new DispatchPdaTask(); + task.setId(201L); task.setCustomerId("1001"); task.setDispatchOrderId(200L); + DispatchItem dispatchItem = new DispatchItem(); + dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L); + dispatchItem.setWarehousePalletId(501L); + dispatchItem.setInboundCode("IN-1"); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(203L); pick.setCustomerId("1001"); pick.setTaskId(201L); + pick.setDispatchItemId(202L); pick.setInventoryPositionId(601L); pick.setPackages(1L); + DispatchPdaPickOrder attribution = new DispatchPdaPickOrder(); + attribution.setId(204L); attribution.setCustomerId("1001"); attribution.setPickId(203L); + attribution.setOrderId(101L); attribution.setPackages(1L); + LastlegOrder tail = new LastlegOrder(); + tail.setId(101L); tail.setCustomerId("1001"); tail.setOrderNo("LM-1"); tail.setInboundCode("IN-1"); + tail.setDeliveryOrderNo("DL-1"); tail.setOutboundOrderNo("OUT-1"); tail.setPackageCount(1); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(102L); inbound.setCustomerId("1001"); inbound.setInboundCode("IN-1"); inbound.setPackageCount(1); + + LastlegOrder secondTail = new LastlegOrder(); + secondTail.setId(111L); secondTail.setCustomerId("1001"); secondTail.setOrderNo("LM-2"); secondTail.setInboundCode("IN-2"); + secondTail.setDeliveryOrderNo("DL-1"); secondTail.setOutboundOrderNo("OUT-1"); secondTail.setPackageCount(3); + LastlegInboundOrder secondInbound = new LastlegInboundOrder(); + secondInbound.setId(112L); secondInbound.setCustomerId("1001"); secondInbound.setInboundCode("IN-2"); secondInbound.setPackageCount(3); + DispatchPdaPick secondPick = new DispatchPdaPick(); + secondPick.setId(213L); secondPick.setCustomerId("1001"); secondPick.setTaskId(201L); + secondPick.setDispatchItemId(202L); secondPick.setInventoryPositionId(602L); secondPick.setPackages(3L); + DispatchPdaPickOrder secondOne = new DispatchPdaPickOrder(); + secondOne.setId(214L); secondOne.setCustomerId("1001"); secondOne.setPickId(213L); secondOne.setOrderId(111L); secondOne.setPackages(1L); + DispatchPdaPickOrder secondTwo = new DispatchPdaPickOrder(); + secondTwo.setId(215L); secondTwo.setCustomerId("1001"); secondTwo.setPickId(213L); secondTwo.setOrderId(111L); secondTwo.setPackages(2L); + + when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + when(lastMileOrders.selectList(any())).thenAnswer(call -> { + var query = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper) call.getArgument(0); + query.getSqlSegment(); + var parameters = query.getParamNameValuePairs().values(); + assertThat(parameters).contains("1001"); + if (parameters.contains("DL-1")) return List.of(tail, secondTail); + if (parameters.contains("LM-1") && parameters.contains("IN-1")) return List.of(tail); + if (parameters.contains("LM-2") && parameters.contains("IN-2")) return List.of(secondTail); + throw new AssertionError("Unexpected tail source query: " + parameters); + }); + when(inboundOrders.selectList(any())).thenAnswer(call -> { + var query = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper) call.getArgument(0); + query.getSqlSegment(); + var parameters = query.getParamNameValuePairs().values(); + assertThat(parameters).contains("1001"); + if (parameters.contains("IN-1")) return List.of(inbound); + if (parameters.contains("IN-2")) return List.of(secondInbound); + throw new AssertionError("Unexpected inbound source query: " + parameters); + }); + when(outboundOrders.findByNo("1001", "OUT-1")).thenReturn(outbound); + OutboundPalletItem otherItem = new OutboundPalletItem(); + otherItem.setId(302L); otherItem.setCustomerId("1001"); otherItem.setOutboundOrderId(300L); + otherItem.setWarehousePalletId(501L); otherItem.setPalletNo("PLT-1"); otherItem.setInboundCode("IN-1"); + if (!"AMBIGUOUS".equals(layout)) { + otherItem.setDestinationWarehouseId("OTHER"); otherItem.setDestinationWarehouseCode("OTHER"); + otherItem.setDestinationWarehouseName("OTHER"); otherItem.setOrderRefs("OTHER"); + } + var outboundSnapshots = "SINGLE".equals(layout) ? List.of(outboundItem) + : "MULTI_REVERSED".equals(layout) ? List.of(otherItem, outboundItem) : List.of(outboundItem, otherItem); + when(outboundPalletItems.findByOutbound("1001", 300L)).thenReturn(outboundSnapshots); + when(outboundPdaPicks.findByOutbound("1001", 300L)).thenReturn(List.of()); + when(dispatchPdaTasks.findByDispatch("1001", 200L)).thenReturn(task); + when(dispatchItems.findByDispatchOrder("1001", 200L)).thenReturn(List.of(dispatchItem)); + when(dispatchPdaPicks.listTask("1001", 201L)).thenReturn(List.of(pick, secondPick)); + when(dispatchPdaPickOrders.listPick("1001", 203L)).thenReturn(List.of(attribution)); + when(lastMileOrders.selectById(101L)).thenReturn(tail); + if (!"AMBIGUOUS".equals(layout)) { + when(dispatchPdaPickOrders.listPick("1001", 213L)).thenReturn(List.of(secondOne, secondTwo)); + when(lastMileOrders.selectById(111L)).thenReturn(secondTail); + } + + if ("AMBIGUOUS".equals(layout)) { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.returnCandidates("1001", "DL-1")) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class).hasMessageContaining("原出库托盘项不唯一"); + return; + } + + var candidates = service.returnCandidates("1001", "DL-1"); + + assertThat(candidates).hasSize(2); + var first = candidates.stream().filter(row -> "IN-1".equals(row.inboundCode())).findFirst().orElseThrow(); + var second = candidates.stream().filter(row -> "IN-2".equals(row.inboundCode())).findFirst().orElseThrow(); + assertThat(first.returnSources()).singleElement().satisfies(source -> { + assertThat(source.outboundPalletItemId()).isEqualTo(301L); + assertThat(source.inventoryPositionId()).isEqualTo(601L); + assertThat(source.maxReturnPackages()).isEqualTo(1L); + }); + assertThat(second.returnSources()).singleElement().satisfies(source -> { + assertThat(source.outboundPalletItemId()).isEqualTo(301L); + assertThat(source.inventoryPositionId()).isEqualTo(602L); + assertThat(source.maxReturnPackages()).isEqualTo(3L); + }); + } + } -- 2.54.0 From 0c8ede3889b71b6a57eda962e3be42b6d8404ee8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 22:13:15 +0800 Subject: [PATCH 124/176] feat(test): continue reviewed return source sample with pre-signature audit --- scripts/README.md | 6 +- ...rn-redispatch-acceptance-contract.test.mjs | 65 +++++++- .../fleet-return-redispatch-acceptance.mjs | 50 +++++- scripts/fleet-test-env-acceptance.mjs | 155 ++++++++++++++++-- scripts/fleet-test-env-acceptance.test.mjs | 87 +++++++++- 5 files changed, 346 insertions(+), 17 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 2c04aaf65..8df15425f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -38,10 +38,14 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 -该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,仅接受全新批次。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 +该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,默认要求全新批次;仅下述精确候选失败检查点允许显式恢复原样本。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。 +`return-redispatch` 在新重派签收前必须等待来源事件只读审计:原余货已签、B 已完成、A 的 2 箱尚未重派签收时,入口持久保存 `before-redispatch-signature.json` 并打印 `FLEET_SIGNATURE_AUDIT_WAITING`,最多等待 20 分钟。总控先核对现场 A 完成事件数为 0、B 为 1,再在同一报告目录提供 `approve-before-redispatch-signature.json`;文件必须含当前 checkpoint 的 `gateNonce`、`marker`、`newTripNo`、`sourceAEventCount:0`、`sourceBEventCount:1`、`auditEvidencePath` 和 `auditEvidenceSha256`,且证据文件须位于仓库 `reports/` 内、哈希相符。签收在此之前不会发送;缺失、超时或证据不符即停止,不自动续跑。新签收及同键重放后,另查 A/B 各一条来源完成事件及 Firstleg 精确 ACK,最终快照不能代替前签时点证据。 + +`RETURN_SOURCE_SELECTION_BEFORE_RETURN` 仅用于 `--scenario return-redispatch` 的明确候选选择失败:原样本已完成唯一 DEPART、交接和成本验证,三张原派送均已通过明确成功响应进入 `DELIVERING`,退回助手只执行了候选 GET 和这三次开始派送,尚未标记退回、建立异常、创建新出库或签收。必须提供原报告精确 `--resume-sha256`,现场复查完整原 DEPART、交接和成本快照、当前车辆/司机身份版本及占用、三单状态版本和修复后的完整 A/B 候选。预检通过后仅新建绑定原仓的 PDA 会话,从原车次退回复查继续,保留原成本/发车/交接,不再次开始派送。前签来源审计闸口仍必须由总控解除;任何先前写入结果不明或现场变化均拒绝恢复,不自动重造。 + `DEPARTED_VEHICLE_CREATED` 是独立的显式恢复阶段:只接受已验证成本、唯一接替车辆创建 POST 明确成功、随后仅因车牌大小写断言停止的报告。执行前核对精确报告 SHA、原成功响应、车辆完整当前快照,并通过 Jenkins 一致性只读查询确认该车辆在 `ll_fleet_resource_assignment` 中没有占用;缺少证据、车辆变更或占用均停止。已创建车辆显式传给交接助手,跳过原车辆创建 POST,从新司机及证照继续;原成本和 DEPART 保留。该成功写入不能借用只读失败恢复阶段忽略,也不得自动重发。 `order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 3c0ba662e..8917cfca6 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { selectReturnSources, verifyFleetReturnRedispatch } from './fleet-return-redispatch-acceptance.mjs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { join, resolve, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { createHash } from 'node:crypto' +import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit } from './fleet-return-redispatch-acceptance.mjs' function fixture(fault = '') { const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] @@ -47,6 +51,11 @@ function fixture(fault = '') { throw Error(`Unexpected ${method} ${path}`) } } const dependencies = { + async onBeforeRedispatchSignature(proof) { + assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING') + if (fault === 'audit-gate') throw Error('Audit gate not approved') + return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } + }, async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true }, async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, @@ -56,6 +65,24 @@ function fixture(fault = '') { } const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) +test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => { + const f = fixture(); f.input.sourceSelectionRecovery = true + assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') + assert.ok(!f.requests.some(row => row.path?.endsWith('/status?customerId=C'))) +}) + +test('reviewed source recovery refuses WAIT_DELIVERY before any delivery-start or return command', async () => { + const f = fixture(); f.input.sourceSelectionRecovery = true + const request = f.context.request + f.context.request = async (...args) => { + const r = await request(...args) + if (r.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') r.body.data.order.status = 'WAIT_DELIVERY' + return r + } + await assert.rejects(run(f), /never start it again/) + assert.ok(f.requests.every(row => row.method === 'GET')) +}) + test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => { const f = fixture(), result = await run(f) assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') @@ -68,6 +95,42 @@ test('fresh return/redispatch chain proves A pending, B complete, then A complet assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')) assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256)) }) + +for (const missing of [false, true]) test(`unapproved or missing pre-signature gate cannot sign or close new trip; missing=${missing}`, async () => { + const f = fixture('audit-gate') + if (missing) delete f.dependencies.onBeforeRedispatchSignature + const original = f.dependencies.saveReceiptAndComplete + f.dependencies.saveReceiptAndComplete = async (...args) => { assert.notEqual(args[1].deliveryNo, 'DL-NEW'); return original(...args) } + f.dependencies.finishFleetTrip = () => assert.fail('new trip must not close before audited signature') + await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/) + assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING') + assert.equal(f.trip.returnRedispatch.afterRedispatch, undefined) +}) + +for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test(`file gate requires exact checkpoint and hashed source audit: ${fault}`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-signature-gate-')), output = join(root, 'reports', 'run') + mkdirSync(output, { recursive: true }) + const context = { marker: 'CURRENT', context: { fleetRunId: 'FRESH' }, createdResources: { fleetAcceptance: { tripNo: 'TR-ORIGINAL', customerId: 'C' } } } + const proof = { newTripNo: 'TR-NEW', sources: { A: { tail: { sourceInboundId: 'A' } }, B: { tail: { sourceInboundId: 'B' } } } } + try { + const wait = async () => { + const checkpoint = JSON.parse(readFileSync(join(output, 'before-redispatch-signature.json'), 'utf8')) + assert.equal(checkpoint.proof.newTripNo, 'TR-NEW'); assert.equal(checkpoint.sourceAInboundId, 'A') + const evidence = Buffer.from('{"A":0,"B":1}') + writeFileSync(join(root, 'reports', 'audit.json'), evidence) + writeFileSync(join(output, 'approve-before-redispatch-signature.json'), JSON.stringify({ gateNonce: fault === 'nonce' ? 'OTHER' : checkpoint.gateNonce, + marker: checkpoint.marker, newTripNo: fault === 'trip' ? 'OTHER' : checkpoint.newTripNo, + sourceAEventCount: fault === 'counts' ? 1 : 0, sourceBEventCount: 1, auditEvidencePath: 'reports/audit.json', + auditEvidenceSha256: fault === 'hash' ? 'a'.repeat(64) : createHash('sha256').update(evidence).digest('hex') })) + } + const promise = waitForRedispatchSignatureAudit(context, proof, { root, output, wait, timeoutMs: fault === 'timeout' ? 0 : 5000 }) + if (fault === 'none') { const result = await promise; assert.equal(result.sourceAEventCount, 0); assert.equal(result.sourceBEventCount, 1) } + else await assert.rejects(promise, /gate|approval|SHA256/) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(resolve(root).startsWith(join(resolve(tmpdir()), 'fleet-signature-gate-'))) + rmSync(root, { recursive: true, force: true }) + } +}) for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) { test(`rejects ${fault} instead of declaring redispatch complete`, async () => { const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index 54f6bfb61..dbb954c24 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -1,3 +1,6 @@ +import {existsSync,openSync,writeFileSync,fsyncSync,closeSync,readFileSync} from 'node:fs' +import {join,resolve} from 'node:path' +import {redactEvidence} from '../fba-core-regression-test-env.mjs' import { randomUUID, createHash } from 'node:crypto' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' @@ -30,7 +33,7 @@ export function selectReturnSources(rows) { return { a, b } } -/** Fresh-run-only stage. Every helper HTTP request is checkpointed before dispatch, including multipart metadata. */ +/** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */ export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { const run = context.createdResources?.fleetAcceptance if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker @@ -71,6 +74,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data } const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete, + onBeforeRedispatchSignature: context.context?.onBeforeRedispatchSignature, wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies } const customerId = id(input.customerId), warehouseId = id(input.warehouseId) const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId)) @@ -98,6 +102,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const current = await delivery(number) check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo)) proof.originalDeliveries.push(copy(current.order)) + if (input.sourceSelectionRecovery) check('Reviewed source recovery must retain the already started delivery; never start it again', current.order.status === 'DELIVERING') if (current.order.status === 'WAIT_DELIVERY') { check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version)) && current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0) @@ -186,6 +191,15 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const departedAgain = await request('POST', departPath, departBody, input.pickerToken) check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2) const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) + proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo, + departed, originalSignatureCount: proof.originalSignatures.length } + await context.checkpoint() + check('Readonly source event audit gate is required before redispatch signature', typeof deps.onBeforeRedispatchSignature === 'function') + const audit = await deps.onBeforeRedispatchSignature(proof) + check('Pre-signature audit proves A has zero completion events and B has one', audit?.sourceAEventCount === 0 + && audit.sourceBEventCount === 1 && /^[a-f0-9]{64}$/.test(audit.auditEvidenceSha256 || '')) + Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit }) + await context.checkpoint() const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271) check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2) const afterA = await tail(a.relation), afterB = await tail(b.relation) @@ -202,3 +216,37 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, await context.checkpoint(); return proof } catch (error) { proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error } } + +export async function waitForRedispatchSignatureAudit(context, proof, { root, output, secrets = [], + wait = ms => new Promise(resolve => setTimeout(resolve, ms)), timeoutMs = 20 * 60 * 1000 } = {}) { + const gateNonce = randomUUID(), approvalPath = join(output, 'approve-before-redispatch-signature.json') + const checkpointPath = join(output, 'before-redispatch-signature.json') + const checkpoint = { gateNonce, marker: context.marker, runId: context.context.fleetRunId, + originalTripNo: context.createdResources.fleetAcceptance.tripNo, newTripNo: proof.newTripNo, + sourceAInboundId: proof.sources.A.tail.sourceInboundId, sourceBInboundId: proof.sources.B.tail.sourceInboundId, + customerId: context.createdResources.fleetAcceptance.customerId, proof, createdResources: context.createdResources } + const descriptor = openSync(checkpointPath, 'wx') + try { writeFileSync(descriptor, JSON.stringify(redactEvidence(checkpoint, secrets), null, 2) + '\n'); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + const progressDescriptor = openSync(join(output, 'progress.json'), 'w') + try { + writeFileSync(progressDescriptor, JSON.stringify(redactEvidence({ stage: 'RETURN_REDISPATCH_PRE_SIGNATURE', marker: context.marker, + steps: context.steps || [], modules: context.modules || [], createdResources: context.createdResources }, secrets), null, 2) + '\n') + fsyncSync(progressDescriptor) + } finally { closeSync(progressDescriptor) } + console.log('FLEET_SIGNATURE_AUDIT_WAITING=' + JSON.stringify({ checkpointPath, approvalPath, gateNonce, marker: context.marker })) + const started = Date.now() + while (!existsSync(approvalPath)) { + if (Date.now() - started >= timeoutMs) throw Error('Pre-signature audit gate timed out; do not sign or resume automatically') + await wait(1000) + } + const approval = JSON.parse(readFileSync(approvalPath, 'utf8')) + if (approval.gateNonce !== gateNonce || approval.marker !== checkpoint.marker || approval.newTripNo !== checkpoint.newTripNo + || approval.sourceAEventCount !== 0 || approval.sourceBEventCount !== 1 || !/^[a-f0-9]{64}$/.test(approval.auditEvidenceSha256 || '') + || !/^reports\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)?\.json$/.test(approval.auditEvidencePath || '')) + throw Error('Pre-signature approval does not match this exact gate and source counts') + const evidencePath = resolve(root, approval.auditEvidencePath) + if (!existsSync(evidencePath) || createHash('sha256').update(readFileSync(evidencePath)).digest('hex') !== approval.auditEvidenceSha256) + throw Error('Pre-signature audit evidence SHA256 mismatch') + return approval +} diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 370dac2e4..3fc65800e 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -13,7 +13,7 @@ import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-exter import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' -import { verifyFleetReturnRedispatch } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' +import { verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit, selectReturnSources } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' @@ -154,11 +154,12 @@ export function parseFleetArguments(args) { } if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') - if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') - if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) + if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED', 'RETURN_SOURCE_SELECTION_BEFORE_RETURN'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (config.resumeStage?.startsWith('DEPARTED_') || config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) throw Error('Departed recovery requires the exact reviewed report SHA256') if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') - if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport)) + if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport && config.resumeStage !== 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') + || (config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' && config.scenario !== 'return-redispatch')) throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint') return config } @@ -169,7 +170,12 @@ export function fleetExecutionPlan(config) { stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, - stages: config.resumeStage?.startsWith('DEPARTED_') + stages: config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' + ? ['Verify exact source-selection-failure report SHA256 and three definitive delivery starts', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions', + 'Recheck repaired A/B return candidates and complete source quantities before any write', 'Create fresh bound PDA session', + 'Original sample actual return and redispatch; no repeated original departure/handover/cost/start-delivery', + 'Wait for coordinator pre-signature A0/B1 event evidence gate', 'New signature and source ACK evidence; close new and original resources'] + : config.resumeStage?.startsWith('DEPARTED_') ? ['Verify reviewed report SHA256', afterCosts ? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries' : 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', @@ -189,6 +195,30 @@ export function fleetExecutionPlan(config) { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) + if (stage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') { + const attempt = trip?.returnRedispatch, originals = attempt?.originalDeliveries, writes = attempt?.requests?.filter(row => row.method !== 'GET') + const failures = report?.steps?.filter(row => row.passed === false) + if (report?.success !== false || proof?.stage !== 'RETURN_REDISPATCH' || proof.status !== 'CHECKING' + || proof.marker !== attempt?.marker || trip?.costAcceptance?.status !== 'VERIFIED' || trip.handover?.status !== 'VERIFIED' + || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip.tripNo + || trip.closure || proof.closure || proof.receipts?.length || attempt.status !== 'FAILED' + || attempt.failure !== 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' + || attempt.exceptionNo || attempt.sources || attempt.newTripNo || attempt.originalSignatures + || originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 3 + || originals.some(row => id(row.customerId) !== id(proof.customerId) || id(row.overseasWarehouseId) !== id(proof.warehouseId) + || !proof.dispatchNos.includes(row.dispatchNo) || row.status !== 'WAIT_DELIVERY' || row.signedAt || Number(row.signedPackages || 0)) + || attempt.requests.some(row => row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200' || !row.requestId || !row.traceId) + || writes.some(row => { + const original = originals.find(item => item.deliveryNo === row.result?.deliveryNo) + return !original || row.method !== 'PUT' || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}/status?customerId=${proof.customerId}` + || row.body?.status !== 'DELIVERING' || id(row.body.expectedVersion) !== id(original.version) + || row.result.deliveryStatus !== 'DELIVERING' || row.result.replayed !== false + }) || new Set(writes.map(row => row.result?.deliveryNo)).size !== 3 + || resources.fleetCostActors?.status !== 'DISABLED' || failures?.length !== 1 || failures[0].name !== '模块执行' + || failures[0].evidence !== attempt.failure) + throw Error('Return source recovery requires the exact known candidate failure and three definitive delivery starts, with no return/signature writes') + return { resources, marker: proof.marker, returnSourceSelection: true } + } if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') { const vehicleCreated = stage === 'DEPARTED_VEHICLE_CREATED' const afterCosts = stage !== 'DEPARTED_BEFORE_COSTS' @@ -321,10 +351,11 @@ export async function verifyFleetTerminalState(context) { } /** All business checks happen before creating replacement test sessions or issuing a business command. */ -export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle } = {}) { +export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle, returnSourceReady = false } = {}) { const proof = context.createdResources.fleetAcceptance const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad + const expectedTrip = returnSourceReady ? trip.handover.finalTrip : snapshot.trip const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` const evidence = { status: 'CHECKING', tripNo: trip.tripNo, requests: [], dispatchDetails: [], deliveries: [] } context.createdResources.fleetDepartureRecoveryVerification = evidence @@ -337,20 +368,43 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f return r.body.data } const current = await get(base) - check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(snapshot.trip.recordVersion) + check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion) && current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId) - && id(current.trip.vehicleId) === id(snapshot.trip.vehicleId) && id(current.trip.driverId) === id(snapshot.trip.driverId) + && id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId) && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') const history = await get(`${base}/history?page=1&size=100`) const departures = history.records?.filter(row => row.action === 'DEPART') check(Array.isArray(history.records) && Number(history.total) === history.records.length && departures?.length === 1 && same(departures[0], original) - && history.records.every(row => BigInt(row.recordVersion) <= BigInt(original.recordVersion)), 'immutable DEPART/history changed') + && history.records.every(row => BigInt(row.recordVersion) <= BigInt(expectedTrip.recordVersion)), 'immutable DEPART/history changed') + if (returnSourceReady) { + const expectedHistory = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/history?`)).at(-1)?.result + check(expectedHistory && same(history.records, expectedHistory.records), 'verified handover history changed') + check(typeof context.context?.readRecoveryTripResources === 'function', 'current resource occupancy reader missing') + const bindings = await context.context.readRecoveryTripResources(trip.tripNo) + check(bindings.length === 2 && bindings.every(row => row.tripNo === trip.tripNo) + && bindings.some(row => row.resourceKind === 'VEHICLE' && id(row.resourceId) === id(expectedTrip.vehicleId)) + && bindings.some(row => row.resourceKind === 'DRIVER' && id(row.resourceId) === id(expectedTrip.driverId)), 'current resources no longer belong to the original trip') + evidence.resourceBindings = bindings + const vehicleSnapshot = trip.handover.requests.find(row => row.method === 'POST' + && row.path === '/api/platform/v1/last-leg/master-data/vehicles' && id(row.result?.id) === id(expectedTrip.vehicleId))?.result + const driverSnapshot = trip.handover.requests.find(row => row.method === 'POST' + && row.path === `/api/platform/v1/last-leg/fleet/drivers/${expectedTrip.driverId}/review`)?.result + check(vehicleSnapshot && driverSnapshot, 'verified current resource snapshots missing') + const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${expectedTrip.vehicleId}`) + const driver = await get(`/api/platform/v1/last-leg/fleet/drivers/${expectedTrip.driverId}`) + check(same(vehicle, vehicleSnapshot), 'current vehicle snapshot changed') + check(['id', 'recordVersion', 'active', 'reviewStatus', 'licenseFileId', 'details'].every(field => same(driver[field], driverSnapshot[field])), 'current driver identity/version/qualification facts changed') + evidence.currentVehicle = vehicle; evidence.currentDriver = driver + } const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`) check((costsAlreadyVerified ? same(costs, trip.costAcceptance.final) : costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0) && costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed') - check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') + if (returnSourceReady) { + const expected = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/handovers?`)).at(-1)?.result + check(expected && Number(handovers.total) === 1 && same(handovers.records, expected.records), 'original verified handover changed') + } else check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) && current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed') for (const number of proof.dispatchNos) { @@ -369,8 +423,12 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f for (const number of deliveryNos) { const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) const order = detail.order + const started = returnSourceReady ? trip.returnRedispatch.requests.find(row => row.method === 'PUT' && row.result?.deliveryNo === number) : null check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo) - && order.status === 'WAIT_DELIVERY' && id(order.version) === '1' && !order.signedAt && !Number(order.signedPackages || 0) + && order.status === (returnSourceReady ? 'DELIVERING' : 'WAIT_DELIVERY') + && id(order.version) === (returnSourceReady ? String(Number(started?.body.expectedVersion) + 1) : '1') + && (!returnSourceReady || order.vehiclePlate === expectedTrip.vehiclePlate && order.driverName === expectedTrip.driverName && order.driverPhone === expectedTrip.driverPhone) + && !order.signedAt && !Number(order.signedPackages || 0) && !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed') evidence.deliveries.push(order) } @@ -409,6 +467,74 @@ export async function readReviewedVehicleOccupation(env, vehicleId, fetcher = gl return result.rows[0] } +export async function readRecoveryTripResources(env, tripNo, fetcher = globalThis.fetch) { + validateEvidenceConfig(env) + if (!/^[A-Za-z0-9_-]{1,128}$/.test(tripNo)) throw Error('Exact recovery trip number required') + const sql = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', + `SELECT JSON_OBJECT('resourceKind',resource_kind,'resourceId',CAST(resource_id AS CHAR),'tripNo',trip_no) FROM ll_fleet_resource_assignment WHERE trip_no='${tripNo}' ORDER BY resource_kind;`, 'COMMIT;'].join('\n') + const script = buildEvidenceRemoteScript('lastleg', sql, env.FBA_CORE_JENKINS_AGENT) + const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', + headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) }) + if (!response.ok) throw Error('Recovery resources readonly HTTP failure') + const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON=')) + if (lines.length !== 1) throw Error('Recovery resource evidence ambiguous') + const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length)) + if (result.ok !== true || !Array.isArray(result.rows)) throw Error('Recovery resource query failed') + return result.rows +} + +export async function verifyReturnSourceRecovery(context, { verifyOriginal = verifyDepartedRecovery } = {}) { + const original = await verifyOriginal(context, { costsAlreadyVerified: true, returnSourceReady: true }) + const run = context.createdResources.fleetAcceptance, rows = [] + const previous = original.trip.returnRedispatch + for (const delivery of previous.originalDeliveries) { + const response = await context.request('GET', `/api/platform/v1/last-leg/delivery-orders/${delivery.deliveryNo}/return-inbound-options?customerId=${run.customerId}`) + if (!success(response) || !response.requestId || !response.traceId || !Array.isArray(response.body.data)) throw Error('Repaired return candidates must be definitive') + for (const relation of response.body.data) { + const tail = await context.request('GET', `/api/platform/v1/last-leg/orders/${relation.lastMileOrderId}?customerId=${run.customerId}`) + if (!success(tail) || !tail.requestId || !tail.traceId || relation.deliveryNo !== delivery.deliveryNo + || relation.outboundNo !== delivery.outboundNo || id(tail.body.data?.order?.customerId) !== id(run.customerId) + || id(tail.body.data.order.overseasWarehouseId) !== id(run.warehouseId) + || id(tail.body.data.order.id) !== id(relation.lastMileOrderId)) throw Error('Repaired source owner or delivery identity mismatch') + rows.push({ relation, tail: tail.body.data.order, delivery }) + } + } + const selected = selectReturnSources(rows) + context.createdResources.fleetDepartureRecoveryVerification.returnSourceSelection = selected + await context.checkpoint() + return original +} + +export async function continueReviewedReturnSources(context, { verify = verifyReturnSourceRecovery, + resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, + redispatch = verifyFleetReturnRedispatch, closeOriginal = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { + const verified = await verify(context), trip = verified.trip, run = context.createdResources.fleetAcceptance + trip.previousSourceSelectionAttempts = [trip.returnRedispatch]; delete trip.returnRedispatch + run.previousPicker = run.picker; delete run.picker + run.pickerMarker = `${context.marker}-RETURN-RECOVERY-${randomUUID().slice(0, 8)}` + await context.checkpoint() + let picker + try { + const child = Object.create(context); child.marker = run.pickerMarker + const access = await resolveAccess(context) + picker = access ? await makePicker(child, 71, run.warehouseId, access) : null + if (!picker?.token || !picker.membershipId) throw Error('Source recovery requires a fresh bound PDA session') + run.picker = { membershipId: picker.membershipId, username: picker.username }; await context.checkpoint() + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const result = await redispatch(context, MODULE, trip, { customerId: run.customerId, warehouseId: run.warehouseId, + pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo], sourceSelectionRecovery: true }) + run.receipts = result.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + context.context.saveFleetReturnRedispatchFixture(result) + run.closure = await closeOriginal(context, MODULE, trip) + if (run.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('Recovered original trip did not close') + await cleanup(context, MODULE, trip) + run.stage = 'FINISHED'; run.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; await context.checkpoint() + return run + } finally { await closePicker(context, picker, 71) } +} + export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) { const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle }) const run = context.createdResources.fleetAcceptance @@ -802,7 +928,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous) if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports') const bytes = readFileSync(previous) - if (config.resumeStage?.startsWith('DEPARTED_') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) + if ((config.resumeStage?.startsWith('DEPARTED_') || config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) throw Error('Reviewed departure report SHA256 mismatch; no environment request sent') resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage) save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage }) @@ -826,6 +952,9 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c context.context.fleetRunId = config.runId context.context.fleetScenario = config.scenario || 'handover' context.context.readReviewedVehicleOccupation = vehicleId => readReviewedVehicleOccupation(env, vehicleId) + context.context.readRecoveryTripResources = tripNo => readRecoveryTripResources(env, tripNo) + context.context.onBeforeRedispatchSignature = proof => waitForRedispatchSignatureAudit(context, proof, + { root, output, secrets: [...secrets, context.token, context.pdaToken, context.refreshToken] }) context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture) let handoverCheckpoint = 0 @@ -839,6 +968,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c } context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) const fleetScenario = async (ctx, options = {}) => { + if (options.returnSourceSelection) return continueReviewedReturnSources(ctx) if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, costsAlreadyVerified: options.costsAlreadyVerified, createdVehicle: options.createdVehicle }) const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) @@ -867,6 +997,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, resumeDeparted: resumed.departed, + returnSourceSelection: resumed.returnSourceSelection, costsAlreadyVerified: resumed.costsAlreadyVerified, createdVehicle: resumed.createdVehicle, resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 5dbb91355..6003bb26a 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -50,6 +50,88 @@ function createdVehicleRecoveryFixture() { return { ...f, snapshot, request } } +function returnSourceRecoveryFixture() { + const f = afterCostsRecoveryFixture(), { report, trip, responses, base, ctx } = f, run = report.createdResources.fleetAcceptance + run.stage = 'RETURN_REDISPATCH'; run.receipts = []; run.departure.shippedPackages = 5 + report.steps = [{ name: '模块执行', passed: false, evidence: 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' }] + const current = { ...responses.get(base).trip, recordVersion: '30', vehicleId: 'V2', driverId: 'D2', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } + responses.get(base).trip = current + const original = trip.costAcceptance.departure + original.snapshot.confirmedLoad.cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'IA', warehousePalletId: 'PA', packages: 3 }, + { dispatchNo: 'OWN', dispatchItemId: 'IB', warehousePalletId: 'PB', packages: 1 }, { dispatchNo: 'EXT', dispatchItemId: 'IX', warehousePalletId: 'PX', packages: 1 }] + const history = { records: [structuredClone(original), { action: 'HANDOVER', recordVersion: '28' }, + { action: 'RELEASE', recordVersion: '29' }, { action: 'RELEASE', recordVersion: '30' }], total: 4 } + responses.set(base + '/history?page=1&size=100', history) + const handover = { records: [{ handoverNo: 'HO-CURRENT', recordVersion: '2' }], total: 1 } + const vehicle = { id: 'V2', recordVersion: '0', plateNumber: 'NEW', status: 'IN_SERVICE' } + const driver = { id: 'D2', recordVersion: '2', active: true, reviewStatus: 'APPROVED', licenseFileId: 'FILE', details: {} } + trip.handover = { status: 'VERIFIED', finalTrip: structuredClone(current), handoverNo: 'HO-CURRENT', requests: [ + { method: 'GET', path: base + '/history?page=1&size=100', result: structuredClone(history) }, + { method: 'GET', path: base + '/handovers?page=1&size=10', result: structuredClone(handover) }, + { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', result: structuredClone(vehicle) }, + { method: 'POST', path: '/api/platform/v1/last-leg/fleet/drivers/D2/review', result: structuredClone(driver) }] } + responses.set(base + '/handovers', handover); responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2', vehicle) + responses.set('/api/platform/v1/last-leg/fleet/drivers/D2', driver) + ctx.context.readRecoveryTripResources = async () => [{ resourceKind: 'VEHICLE', resourceId: 'V2', tripNo: 'TR-OWN' }, { resourceKind: 'DRIVER', resourceId: 'D2', tripNo: 'TR-OWN' }] + const rows = [['A', 3, 'OWN'], ['B', 1, 'OWN'], ['EXT', 1, 'EXT']] + const originals = rows.map(([label, packages, dispatchNo]) => ({ deliveryNo: 'DL-' + label, outboundNo: 'OUT-' + label, dispatchNo, + customerId: 'C1', overseasWarehouseId: 'W1', status: 'WAIT_DELIVERY', version: '2', packageCount: packages, + documentSummary: { categories: [{ fileCount: 0 }] } })) + trip.returnRedispatch = { marker: 'RUN', status: 'FAILED', failure: report.steps[0].evidence, originalDeliveries: originals, + requests: originals.map(order => ({ method: 'PUT', path: `/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/status?customerId=C1`, + body: { status: 'DELIVERING', expectedVersion: 2 }, status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't', + result: { deliveryNo: order.deliveryNo, deliveryStatus: 'DELIVERING', replayed: false } })) } + report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos = ['DL-A', 'DL-B'] + for (const [label, packages, dispatchNo] of rows) { + const order = originals.find(row => row.deliveryNo === 'DL-' + label) + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`, { order: { ...structuredClone(order), + status: 'DELIVERING', version: '3', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } }) + const relation = { lastMileOrderId: label, inboundCode: 'IN-' + label, deliveryNo: order.deliveryNo, outboundNo: order.outboundNo, + returnSources: [{ maxReturnPackages: packages }] } + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`, [relation]) + responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`, { order: { id: label, customerId: 'C1', overseasWarehouseId: 'W1', + orderType: label === 'EXT' ? 'WAREHOUSE_TRANSFER' : 'FIRST_LEG', sourceInboundId: 'SOURCE-' + label, inboundCode: relation.inboundCode, packageCount: packages } }) + } + for (const no of ['OWN', 'EXT']) { + const cargo = original.snapshot.confirmedLoad.cargo.filter(row => row.dispatchNo === no), total = cargo.reduce((n,row)=>n+row.packages,0) + responses.set('/api/platform/v1/last-leg/dispatches/' + no, { dispatchNo: no, status: 'DEPARTED', overseasWarehouseId: 'W1', + packageCount: total, pickedPackageCount: total, shippedPackageCount: total, + items: cargo.map(row=>({id:row.dispatchItemId,warehousePalletId:row.warehousePalletId,packageCount:row.packages})) }) + } + return f +} + +test('source-selection recovery only accepts original three explicit starts and rejects any actual return or unsigned result', () => { + const f = returnSourceRecoveryFixture() + assert.equal(reviewedFullCheckpoint(f.report, 'RETURN_SOURCE_SELECTION_BEFORE_RETURN').returnSourceSelection, true) + for (const change of [x=>x.trip.returnRedispatch.requests.push({ method:'POST',path:'/mark-return' }), + x=>x.trip.returnRedispatch.requests[0].status='UNKNOWN_RESULT',x=>x.trip.returnRedispatch.requests[0].result.replayed=true]) { + const mutated = returnSourceRecoveryFixture(); change(mutated) + assert.throws(()=>reviewedFullCheckpoint(mutated.report,'RETURN_SOURCE_SELECTION_BEFORE_RETURN'), /source recovery/) + } +}) + +for (const fault of ['none','candidate-empty','delivery-waiting','resource-wrong','cost-changed','handover-changed']) + test(`source recovery validates current business facts and repaired candidates before PDA write: ${fault}`,async()=>{ + const f=returnSourceRecoveryFixture() + if(fault==='candidate-empty')f.responses.get('/api/platform/v1/last-leg/delivery-orders/DL-A/return-inbound-options?customerId=C1')[0].returnSources=[] + if(fault==='delivery-waiting')f.responses.get('/api/platform/v1/last-leg/delivery-orders/DL-A?customerId=C1').order.status='WAIT_DELIVERY' + if(fault==='resource-wrong')f.ctx.context.readRecoveryTripResources=async()=>[] + if(fault==='cost-changed')f.responses.get(f.base+'/costs?page=1&size=100').summary.totals[0].directCost='99.00' + if(fault==='handover-changed')f.responses.get(f.base+'/handovers').records[0].recordVersion='3' + if(fault==='none'){await verifyReturnSourceRecovery(f.ctx);assert.equal(f.ctx.createdResources.fleetDepartureRecoveryVerification.returnSourceSelection.a.tail.packageCount,3)} + else await assert.rejects(continueReviewedReturnSources(f.ctx,{makePicker:()=>assert.fail('no PDA write before verification')})) + }) + +test('source recovery preserves original costs/handover and invokes only actual return continuation and closure',async()=>{ + const f=returnSourceRecoveryFixture(), operations=[], costs=f.trip.costAcceptance,hand=f.trip.handover + f.ctx.context.saveFleetReturnRedispatchFixture=()=>operations.push('save-return-proof') + await continueReviewedReturnSources(f.ctx,{resolveAccess:async()=>({}),makePicker:async()=>({token:'new-pda',membershipId:'NEW'}),closePicker:async()=>operations.push('close-pda'), + redispatch:async(_ctx,_module,trip,input)=>{assert.equal(input.sourceSelectionRecovery,true);assert.equal(trip.costAcceptance,costs);assert.equal(trip.handover,hand);operations.push('return-candidates');return {originalSignatures:[{deliveryNo:'DL-A',signedAt:'signed'}]}}, + closeOriginal:async()=>{operations.push('close-original');return {status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},cleanup:async()=>operations.push('cleanup')}) + assert.deepEqual(operations,['return-candidates','save-return-proof','close-original','cleanup','close-pda']) +}) + test('created-vehicle checkpoint requires exact successful uppercase response and no subsequent writes', () => { const { report } = createdVehicleRecoveryFixture() const accepted = reviewedFullCheckpoint(report, 'DEPARTED_VEHICLE_CREATED') @@ -201,7 +283,7 @@ test('changed source report SHA blocks recovery before creating context or loggi rmSync(temporary, { recursive: true, force: true }) } }) -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation } from './fleet-test-env-acceptance.mjs' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, @@ -500,7 +582,8 @@ test('phone lookup failure stops without any user creation or guessing', async ( }) test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { - const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') + const fullSource = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') + const source = fullSource.slice(fullSource.indexOf('export async function runFleetScenario')) const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip') const cleanup = source.indexOf('await cleanup(context, MODULE, trip)') -- 2.54.0 From 1f7ddf7bc9e2e0077b4ed15b7ce3fd7c4f628197 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 23:09:24 +0800 Subject: [PATCH 125/176] fix(delivery): expose signed quantity and publish correct signature instant --- ...OverseasDeliveryCompletedConsumerTest.java | 28 +++++++++++++- .../delivery/dto/DeliveryResponses.java | 4 +- .../service/DeliveryDocumentService.java | 7 ++-- .../service/DeliveryTransferService.java | 2 +- .../openapi/overseas-business-v1.yaml | 37 +++++++++++++++++-- .../DeliveryLongIdJsonContractTest.java | 20 +++++++++- .../DeliveryCompletedOutboxServiceTest.java | 10 ++++- .../service/DeliveryDocumentServiceTest.java | 28 ++++++++++++++ .../service/DeliveryTransferServiceTest.java | 12 ++++++ ...rn-redispatch-acceptance-contract.test.mjs | 23 ++++++++++++ .../fleet-return-redispatch-acceptance.mjs | 7 ++-- 11 files changed, 162 insertions(+), 16 deletions(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java index e8f3091b2..0cb1988f8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -18,6 +18,30 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class OverseasDeliveryCompletedConsumerTest { + @Test + void preservesTheInstantOfAShanghaiSignatureInUtcInboundAndHistory() { + var inbounds = mock(InboundOrderMapper.class); + var histories = mock(InboundStatusHistoryMapper.class); + var inbox = mock(ContainerEventInboxMapper.class); + var orders = mock(OrderFulfillmentCompletionClient.class); + var json = new ObjectMapper().registerModule(new JavaTimeModule()); + var consumer = new OverseasDeliveryCompletedConsumer(inbounds, histories, inbox, orders, json); + var inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(1); + var signature = java.time.LocalDateTime.of(2026, 10, 3, 22, 13) + .atZone(java.time.ZoneId.of("Asia/Shanghai")).toInstant(); + consumer.consume(event(json, "201", signature)); + var expected = java.time.LocalDateTime.of(2026, 10, 3, 14, 13); + verify(inbounds).completeOverseasDelivery(88L, 101L, expected); + verify(histories).insert(argThat((com.baiyue.firstleg.entity.InboundStatusHistoryEntity history) -> expected.equals(history.getOccurredAt()) + && "LOADED".equals(history.getFromStatus()) && "DELIVERED".equals(history.getToStatus()))); + verifyNoInteractions(orders); + } @Test void completesOnlyEventSourceInboundAndOrder() { InboundOrderMapper inbounds = mock(InboundOrderMapper.class); @@ -111,7 +135,9 @@ class OverseasDeliveryCompletedConsumerTest { private EventEnvelope event(ObjectMapper json) { return event(json, "201"); } private EventEnvelope event(ObjectMapper json, String rootOrderId) { - Instant time = Instant.parse("2026-09-15T08:00:00Z"); + return event(json, rootOrderId, Instant.parse("2026-09-15T08:00:00Z")); + } + private EventEnvelope event(ObjectMapper json, String rootOrderId, Instant time) { var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11", "101", "IN-1", "201", "FL-201", rootOrderId, "ONT8", time); var node = json.valueToTree(payload); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index bed422a5f..2fb6c2c51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.delivery.dto; import java.time.LocalDateTime; import java.math.BigDecimal; import java.util.List; +import com.fasterxml.jackson.annotation.JsonInclude; public final class DeliveryResponses { private DeliveryResponses() {} @@ -27,7 +28,8 @@ public final class DeliveryResponses { String loadingType, String vehicleType, String vehicleSize, String vehiclePlate, String driverName, String driverPhone, String appointmentAccount, String sellerName, int palletCount, - int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, + int packageCount, @JsonInclude(JsonInclude.Include.ALWAYS) Integer signedPackages, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, String remark, LocalDateTime createdAt, long version, List allowedActions, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index d774daa34..9a2871ce0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.security.*; import java.time.LocalDateTime; -import java.time.ZoneOffset; +import java.time.ZoneId; import java.util.*; @Service @@ -86,7 +86,8 @@ public class DeliveryDocumentService { List values = bindings.getOrDefault(category, List.of()); return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values); }).toList(); - LocalDateTime now = LocalDateTime.now(); + ZoneId executionZone = ZoneId.systemDefault(); + LocalDateTime now = LocalDateTime.now(executionZone); boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty(); boolean autoCompleted = receiptSaved && order.getSignedAt() == null && !"DELIVERED".equals(order.getStatus()); int signedPackages = 0; @@ -113,7 +114,7 @@ public class DeliveryDocumentService { orders.updateById(order); if (autoCompleted) { var completion = completeRelatedOrders(order, operatorId, now); - deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.toInstant(ZoneOffset.UTC)); + deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); customerVisits.ensureForCompletedDelivery(order, now); } Detail result = detail(order, false); 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 4b5360343..06c3ed9aa 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 @@ -1438,7 +1438,7 @@ public class DeliveryTransferService { allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3), allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4)); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } 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 63b1de0d5..4345d8118 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -848,14 +848,14 @@ paths: summary: 分页查询平台派送单 description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。同一排车按目的FBA仓显示为不同派送单,每行返回目的仓快照和服务端计算的 allowedActions,页面不得自行推导状态门禁;完成动作不再由人工触发。 security: [{ bearerAuth: [] }] - responses: { '200': { description: 派送单分页 } } + responses: { '200': { description: 派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}: get: summary: 查询平台派送单详情 description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: '派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓;fees附reviewStatus、sourceStatus、voidStatus(NOT_REQUESTED/PENDING_FINANCE/FINANCE_ATTENTION/CONFIRMED),不得把待财务确认或待处理显示为已作废。feeCoverage为可空的原费用服务覆盖确认快照,只包含当前派送所属货物、引用的原费用版本及服务、确认人和时间;不代表新增收费或原费用已付款。' }, '404': { description: 派送单不存在 } } + responses: { '200': { description: '派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓;fees附reviewStatus、sourceStatus、voidStatus(NOT_REQUESTED/PENDING_FINANCE/FINANCE_ATTENTION/CONFIRMED),不得把待财务确认或待处理显示为已作废。feeCoverage为可空的原费用服务覆盖确认快照,只包含当前派送所属货物、引用的原费用版本及服务、确认人和时间;不代表新增收费或原费用已付款。', content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderDetailResponse' } } } }, '404': { description: 派送单不存在 } } /api/platform/v1/last-leg/exception-orders: get: summary: 分页查询异常订单 @@ -1052,7 +1052,7 @@ paths: - { name: deliveryMethod, in: query, schema: { type: string } } - { name: plannedFrom, in: query, schema: { type: string, format: date-time } } - { name: plannedTo, in: query, schema: { type: string, format: date-time } } - responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } } + responses: { '200': { description: 当前客户派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } }, '403': { description: 缺少客户派送查看权限 } } post: summary: 客户从自己的排车单创建派送单 description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。 @@ -1066,7 +1066,7 @@ paths: description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } + responses: { '200': { description: 当前客户派送单详情, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderDetailResponse' } } } }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } /api/merchant/v1/last-leg/orders: get: summary: 客户分页查询自己的海外尾程订单 @@ -1748,6 +1748,35 @@ paths: responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } components: schemas: + DeliveryOrderRow: + type: object + required: [deliveryNo, packageCount, signedPackages] + properties: + deliveryNo: { type: string } + packageCount: { type: integer, minimum: 0 } + signedPackages: + type: integer + nullable: true + minimum: 0 + description: 数据库冻结的实际签收箱数;字段始终返回,null 表示未知,0 与正整数保留原值,不以发货箱数替代。 + signedAt: { type: string, format: date-time, nullable: true } + DeliveryOrderPageResponse: + type: object + properties: + code: { type: string } + data: + type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/DeliveryOrderRow' } } + DeliveryOrderDetailResponse: + type: object + properties: + code: { type: string } + data: + type: object + required: [order] + properties: + order: { $ref: '#/components/schemas/DeliveryOrderRow' } ReturnOrderCreate: type: object required: [overseasWarehouseId, needRelabel, idempotencyKey, items] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index b5bcd85d8..c3ef538c7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -61,12 +61,28 @@ class DeliveryLongIdJsonContractTest { }); } - private DeliveryResponses.DeliveryRow deliveryRow() { + @Test + void signedPackagesMustPreserveNullZeroAndPositiveDatabaseFacts() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); + for (Integer count : new Integer[] {null, 0, 3}) { + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(deliveryRow(count))); + assertThat(json.has("signedPackages")).isTrue(); + if (count == null) assertThat(json.get("signedPackages").isNull()).isTrue(); + else assertThat(json.get("signedPackages").intValue()).isEqualTo(count); + } + }); + } + + private DeliveryResponses.DeliveryRow deliveryRow() { return deliveryRow(null); } + + private DeliveryResponses.DeliveryRow deliveryRow(Integer signedPackages) { return new DeliveryResponses.DeliveryRow( DELIVERY_ID, null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, - 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, + 0, 0, signedPackages, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, com.baiyue.lastleg.delivery.service.DeliveryDocumentSnapshot.summary(null), null, null, null, null, null, null, null, null); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 7eae1933b..20ae38df0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -34,7 +34,8 @@ class DeliveryCompletedOutboxServiceTest { delivery.setPackageCount(10); delivery.setSignedPackages(10); service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), - order(12, "102", "202", "IN-2", "ACY2")), proofs(), Instant.parse("2026-09-15T08:00:00Z")); + order(12, "102", "202", "IN-2", "ACY2")), proofs(), + java.time.LocalDateTime.of(2026, 10, 3, 22, 13).atZone(java.time.ZoneId.of("Asia/Shanghai")).toInstant()); ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegEventOutbox.class); verify(mapper, times(2)).insert(rows.capture()); @@ -48,6 +49,13 @@ class DeliveryCompletedOutboxServiceTest { .containsExactly("101", "102"); assertThat(payloads).extracting(OverseasDeliveryCompletedV1::fbaWarehouseCode) .containsExactly("ONT8", "ACY2"); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::completedAt) + .containsOnly(Instant.parse("2026-10-03T14:13:00Z")); + for (var row : rows.getAllValues()) { + assertThat(json.readValue(row.getPayloadJson(), EventEnvelope.class).occurredAt()) + .isEqualTo(Instant.parse("2026-10-03T14:13:00Z")); + assertThat(row.getCreatedAt()).isEqualTo(java.time.LocalDateTime.of(2026, 10, 3, 14, 13)); + } } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index 2fbfbf6b8..85bfd8c45 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -18,6 +18,13 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockMultipartFile; import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.TimeZone; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.parallel.ResourceLock; import java.util.List; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -174,6 +181,27 @@ class DeliveryDocumentServiceTest { assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY"); assertThat(order.getVersion()).isZero(); } + @ParameterizedTest + @ValueSource(strings = { "Asia/Shanghai", "America/Los_Angeles" }) + @ResourceLock("java.util.TimeZone.default") + void signatureEventUsesTheExecutionZoneInsteadOfReinterpretingLocalTimeAsUtc(String zone) { + TimeZone previous = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(zone)); + order.setStatus("WAIT_DELIVERY"); + var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED"); + when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); + when(orders.lockByNo("100", "DL-1")).thenReturn(order); + when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13)); + Instant before = Instant.now(); + service.save("100", "DL-1", "9", request(0, "receipt-zone", List.of(new Binding(RECEIPT, List.of(13L))))); + ArgumentCaptor completed = ArgumentCaptor.forClass(Instant.class); + verify(deliveryCompletedOutbox).publish(eq(order), anyList(), anyMap(), completed.capture()); + assertThat(completed.getValue()).isEqualTo(order.getSignedAt().atZone(ZoneId.of(zone)).toInstant()); + assertThat(completed.getValue()).isBetween(before, Instant.now()); + assertThat(completed.getValue()).isNotEqualTo(order.getSignedAt().toInstant(ZoneOffset.UTC)); + } finally { TimeZone.setDefault(previous); } + } @Test void bolMayBePreparedBeforeOutboundWithoutCompletingDelivery() { order.setStatus("WAIT_DELIVERY"); when(orders.lockByNo("100","DL-1")).thenReturn(order); 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 6b9e4d37d..01b81fe0b 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 @@ -544,6 +544,18 @@ class DeliveryTransferServiceTest { verify(tailOrders).selectBatchIds(List.of(901L)); } + @Test + void deliveryRowMapsOnlyTheNullableStoredSignedQuantityWithoutShipmentFallback() { + DeliveryOrder order = new DeliveryOrder(); + order.setId(401L); order.setDeliveryNo("DEL-401"); order.setPackageCount(9); + for (Integer signed : new Integer[] {null, 0, 3}) { + order.setSignedPackages(signed); + DeliveryResponses.DeliveryRow row = org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "deliveryRow", order); + assertEquals(signed, row.signedPackages()); + assertEquals(9, row.packageCount()); + } + } + @Test void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 8917cfca6..cdd70dd09 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -65,6 +65,29 @@ function fixture(fault = '') { } const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) +for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`unknown or invalid original signed quantity ${signedPackages} stops before redispatch`, async () => { + const f = fixture(), save = f.dependencies.saveReceiptAndComplete + f.dependencies.saveReceiptAndComplete = async (...args) => { + const result = await save(...args) + result.order.signedPackages = signedPackages + return result + } + f.dependencies.prepareFleetTrip = () => assert.fail('invalid signed quantity must stop before preparing redispatch') + await assert.rejects(run(f), /stored quantity/) + assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0) +}) + +for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requires actual numeric two signed boxes: ${signedPackages}`, async () => { + const f = fixture(), save = f.dependencies.saveReceiptAndComplete + f.dependencies.saveReceiptAndComplete = async (...args) => { + const result = await save(...args) + if (args[1].deliveryNo === 'DL-NEW') result.order.signedPackages = signedPackages + return result + } + f.dependencies.finishFleetTrip = () => assert.fail('invalid new signature must not release trip resources') + await assert.rejects(run(f), /two boxes actually signed/) +}) + test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => { const f = fixture(); f.input.sourceSelectionRecovery = true assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index dbb954c24..4a2fffba4 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -154,7 +154,8 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, for (const [index, number] of input.deliveryNos.entries()) { const current = await delivery(number) const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index) - check('Original remaining cargo actually signed', signed?.order?.signedAt) + check('Original remaining cargo actually signed with a stored quantity', signed?.order?.signedAt + && Number.isSafeInteger(signed.order.signedPackages) && signed.order.signedPackages >= 0) proof.originalSignatures.push(copy(signed.order)) } const beforeA = await tail(a.relation), beforeB = await tail(b.relation) @@ -201,12 +202,12 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit }) await context.checkpoint() const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271) - check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2) + check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && newSignature.order.signedPackages === 2) const afterA = await tail(a.relation), afterB = await tail(b.relation) check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus) for (const original of proof.originalSignatures) { const current = (await delivery(original.deliveryNo)).order - check('Original signed facts stay immutable', current.signedAt === original.signedAt && Number(current.signedPackages) === Number(original.signedPackages) && Number(current.packageCount) === Number(original.packageCount)) + check('Original signed facts stay immutable', current.signedAt === original.signedAt && current.signedPackages === original.signedPackages && Number(current.packageCount) === Number(original.packageCount)) } proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed } proof.domesticAfterRedispatch = await domesticBoundary(a, b, true) -- 2.54.0 From 19e9913f30e66a12b1b12bbbc06b768dab23022e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 23:39:06 +0800 Subject: [PATCH 126/176] fix(acceptance): target deployed TEST Firstleg container for read-only audits --- scripts/fba-core-regression-evidence.mjs | 2 +- scripts/fba-core-regression-evidence.test.mjs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/fba-core-regression-evidence.mjs b/scripts/fba-core-regression-evidence.mjs index 24dddbb64..fc8283591 100644 --- a/scripts/fba-core-regression-evidence.mjs +++ b/scripts/fba-core-regression-evidence.mjs @@ -131,7 +131,7 @@ const base64Expression = value => '['+Buffer.from(value).toString('base64').matc export function buildEvidenceRemoteScript(service, sql, agent) { assertReadOnlySql(sql) if (!['order','firstleg','lastleg'].includes(service) || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent)) throw Error('Invalid TEST evidence target') - const prefix=service.toUpperCase(),container=`firstleg-test-${service}` + const prefix=service.toUpperCase(),container=service==='firstleg'?'firstleg-test-business':`firstleg-test-${service}` const python = `import json,os,subprocess,urllib.parse,base64,re try: c=json.loads(subprocess.check_output(['docker','inspect','${container}'],text=True,stderr=subprocess.DEVNULL))[0] diff --git a/scripts/fba-core-regression-evidence.test.mjs b/scripts/fba-core-regression-evidence.test.mjs index 206ea130d..fd6f99385 100644 --- a/scripts/fba-core-regression-evidence.test.mjs +++ b/scripts/fba-core-regression-evidence.test.mjs @@ -63,8 +63,8 @@ test('Firstleg receiver evidence stays on its exact TEST container and rejects w const sql = buildCompletionEvidenceSql(completion()) const decode = value => Buffer.from([...value.matchAll(/'([A-Za-z0-9+/=]{40,})'/g)].map(m => m[1]).join(''), 'base64').toString('utf8') const python = decode(decode(buildEvidenceRemoteScript('firstleg', sql, 'java-test-agent'))) - assert.match(python, /docker','inspect','firstleg-test-firstleg'/) - assert.match(python, /get\('Name'\)!='\/firstleg-test-firstleg'/) + assert.match(python, /docker','inspect','firstleg-test-business'/) + assert.match(python, /get\('Name'\)!='\/firstleg-test-business'/) assert.match(python, /com\.docker\.compose\.project'\)!='firstleg-test'/) assert.match(python, /e\['FIRSTLEG_DB_URL'\]/) assert.match(python, /e\['FIRSTLEG_DB_USERNAME'\]/) -- 2.54.0 From 3f6083ff029ed18946a1a53e3c5aa2fa82ea08cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 23:58:47 +0800 Subject: [PATCH 127/176] fix(acceptance): keep safe fleet metadata clear of sensitive report fields --- ...leet-handover-acceptance-contract.test.mjs | 3 ++- .../fleet-handover-acceptance.mjs | 2 +- ...rn-redispatch-acceptance-contract.test.mjs | 3 +++ .../fleet-return-redispatch-acceptance.mjs | 2 +- scripts/fleet-test-env-acceptance.mjs | 21 +++++++++++++++++-- scripts/fleet-test-env-acceptance.test.mjs | 11 ++++++++++ 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs index f3e99b6f4..7b20a2b86 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -210,7 +210,8 @@ test('missing persistence and failed pre-send checkpoints cannot send HTTP', asy assert.ok(blocked) assert.ok(!f.calls.some(row => row.method === blocked.method && row.path === blocked.path)) if (target === 'multipart') { - assert.match(blocked.file.sha256, /^[a-f0-9]{64}$/); assert.ok(blocked.file.bytes > 0) + assert.match(blocked.file.sha256, /^[a-f0-9]{64}$/); assert.ok(blocked.file.sizeBytes > 0) + assert.equal(Object.hasOwn(blocked.file, 'bytes'), false) assert.equal(blocked.file.content, undefined) } } diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs index b83e0b1eb..086fcf118 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -38,7 +38,7 @@ function io(context, module, proof) { } async function request(method, path, body, label, { token, conflict, file } = {}) { const entry = { method, path, label, actor: token ? 'PDA' : 'PLATFORM', body: clone(body), status: 'SENDING' } - if (file) entry.file = { name: file.name, type: file.type, sha256: createHash('sha256').update(file.content).digest('hex'), bytes: file.content.length } + if (file) entry.file = { name: file.name, type: file.type, sha256: createHash('sha256').update(file.content).digest('hex'), sizeBytes: file.content.length } proof.requests.push(entry) // A failed write must stop before HTTP. Keep the durable SENDING record if the process dies in flight. await checkpoint(proof) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index cdd70dd09..64e564473 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -117,6 +117,9 @@ test('fresh return/redispatch chain proves A pending, B complete, then A complet assert.equal(result.afterRedispatch.departed.shippedPackages, 2) assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')) assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256)) + const uploads = f.trip.returnRedispatch.requests.filter(row => row.file) + assert.ok(uploads.length > 0) + assert.ok(uploads.every(row => Number.isSafeInteger(row.file.sizeBytes) && row.file.sizeBytes > 0 && !Object.hasOwn(row.file, 'bytes'))) }) for (const missing of [false, true]) test(`unapproved or missing pre-signature gate cannot sign or close new trip; missing=${missing}`, async () => { diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index 4a2fffba4..3fb9d3c2b 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -51,7 +51,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, io.assert = (...args) => { context.assert(...args); if (!args[3]) throw Error(args[1]) } async function invoke(method, path, body, token, file, options) { const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' } - if (file) entry.file = { name: file.name, type: file.type, bytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } + if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } proof.requests.push(entry); await context.checkpoint() try { const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 3fc65800e..79b8b54ff 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -573,18 +573,35 @@ export function fleetCostShares(trip, dispatchDetails) { return shares } +export function fleetCostActorJournalData(value) { + const clean = redactEvidence(value) + function removeCredentials(item) { + if (Array.isArray(item)) return item.map(removeCredentials) + if (!item || typeof item !== 'object') return item + const result = {}, forbidden = /^(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)$/i + let removed = false + for (const [name, field] of Object.entries(item)) { + if (forbidden.test(name)) removed = true + else result[name] = removeCredentials(field) + } + if (removed) result.credentialFieldsRemoved = true + return result + } + return removeCredentials(clean) +} + export async function prepareFleetCostActors(context) { if (!context.marker || context.createdResources.fleetCostActors || typeof context.checkpoint !== 'function') throw Error('Cost actors require fresh current-run ownership and durable checkpoints') const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CREATING', actors: [], requests: [] } const base = '/api/platform/v1/iam', sessions = [] async function request(method, path, body, token) { - const entry = { method, path, body: redactEvidence(body), status: 'SENDING' } + const entry = { method, path, body: fleetCostActorJournalData(body), status: 'SENDING' } proof.requests.push(entry); await context.checkpoint() try { const response = await context.request(method, path, body, token) Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, - requestId: response?.requestId, traceId: response?.traceId, result: redactEvidence(response?.body?.data) }) + requestId: response?.requestId, traceId: response?.traceId, result: fleetCostActorJournalData(response?.body?.data) }) await context.checkpoint() if (!success(response) || !response.requestId || !response.traceId) throw Error(`Cost actor request failed: ${method} ${path}`) return response.body.data diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 6003bb26a..58682d9d8 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -519,6 +519,17 @@ test('missing isolated cost actor preparation rejects before readers or fixture assert.equal(touched, false) }) +test('cost actor journal removes credential keys while retaining IDs, roles and boolean removal evidence', async () => { + const { fleetCostActorJournalData } = await import('./fleet-test-env-acceptance.mjs') + const source = { membershipId: '12', roleIds: ['7'], password: 'actual-secret', + nested: { accessToken: 'actual-session', refreshToken: 'actual-refresh', status: 'ACTIVE' } } + const result = fleetCostActorJournalData(source) + assert.deepEqual(result, { membershipId: '12', roleIds: ['7'], credentialFieldsRemoved: true, + nested: { status: 'ACTIVE', credentialFieldsRemoved: true } }) + assert.doesNotMatch(JSON.stringify(result), /"(?:password|accessToken|refreshToken)"\s*:/) + assert.equal(source.nested.accessToken, 'actual-session') +}) + for (const failLogin of [false, true]) test(`cost actors create exact isolated roles and disable only owned accounts; login failure=${failLogin}`, async () => { const calls = [], saved = [], roles = [], users = [] const context = { marker: 'RUN', token: 'admin-secret', createdResources: {}, async findAvailablePhone() { return '19900000000' }, -- 2.54.0 From e8cf5e70ba8b6be2ac53a8fa68b31b00fdeed0ac Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 00:04:36 +0800 Subject: [PATCH 128/176] test(order): verify warehouse faults and await recovery projection --- scripts/README.md | 2 +- ...rs-lifecycle-idempotency-contract.test.mjs | 21 ++-- scripts/admin-test-env-modules/orders.mjs | 8 +- ...order-submit-dependency-fault-executor.mjs | 83 ++++++++++---- ...-submit-dependency-fault-executor.test.mjs | 108 ++++++++++++++++-- .../order-submit-dependency-fault-proxy.py | 10 +- ...der-submit-dependency-fault-proxy.test.mjs | 24 +++- .../order-submit-dependency-fault-runtime.py | 24 +++- 8 files changed, 229 insertions(+), 51 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 8df15425f..8da16712e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -48,7 +48,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `DEPARTED_VEHICLE_CREATED` 是独立的显式恢复阶段:只接受已验证成本、唯一接替车辆创建 POST 明确成功、随后仅因车牌大小写断言停止的报告。执行前核对精确报告 SHA、原成功响应、车辆完整当前快照,并通过 Jenkins 一致性只读查询确认该车辆在 `ll_fleet_resource_assignment` 中没有占用;缺少证据、车辆变更或占用均停止。已创建车辆显式传给交接助手,跳过原车辆创建 POST,从新司机及证照继续;原成本和 DEPART 保留。该成功写入不能借用只读失败恢复阶段忽略,也不得自动重发。 -`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 +`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格、Finance 钱包及两张草稿共同引用的公共收货仓 GET 注入短时故障;Warehouse 阶段还限定原请求 ID、Order 来源与内部身份,不影响其他客户对同仓的读取。验证三依赖、两端共六次提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取;业务恢复必须等实际唯一入库单的SUBMITTED投影收敛,不能以连续空结果认定完成,随后按原版本原键重放并核对完整快照,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="" --env-file=""` 提供,数据库凭据只在执行节点内存读取。 diff --git a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs index 2de2df749..12f364596 100644 --- a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs @@ -6,7 +6,7 @@ import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationTyp buildOrderDependencyFaultPlan, assertOrderDependencyFaultObservation } from './orders.mjs' function dependencyFixture() { - const detail = id => ({order:{id,orderNo:`FL${id}`,tenantId:'20',status:'DRAFT',version:4}, + const detail = id => ({order:{id,orderNo:`FL${id}`,tenantId:'20',receivingWarehouseId:'30',status:'DRAFT',version:4}, cargoes:[{skuNo:'current'}],parties:[],internalNotes:[],statusHistory:[]}) return {marker:'CURRENT-RUN',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')} } @@ -20,23 +20,28 @@ function dependencyObservation(plan, fixture, dependency, role) { test('dependency plan binds two new-customer drafts and only named Order upstream properties; it performs no runtime actions',()=>{ const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture) assert.equal(plan.runtimeChangesExecuted,false) - assert.deepEqual(plan.phases.map(row=>row.urlProperty),['baiyue.clients.iam.url','baiyue.clients.finance.url']) + assert.deepEqual(plan.phases.map(row=>row.urlProperty),['baiyue.clients.iam.url','baiyue.clients.finance.url','baiyue.clients.warehouse.url']) + assert.equal(plan.receivingWarehouseId,'30') + assert.equal(plan.phases[2].targetPath,'/internal/warehouses/0/30/active') assert.equal(plan.commands.merchant.body.expectedVersion,4) fixture.merchant.order.version=9 assert.equal(plan.commands.merchant.body.expectedVersion,4) assert.ok(Object.isFrozen(plan.commands.merchant.body)) - for(const change of [f=>f.customerId='0',f=>f.registeredCustomerId='21',f=>f.platform.order.id='101',f=>f.merchant.order.status='SUBMITTED',f=>delete f.merchant.cargoes]) { + for(const change of [f=>f.customerId='0',f=>f.registeredCustomerId='21',f=>f.platform.order.id='101',f=>f.merchant.order.status='SUBMITTED',f=>delete f.merchant.cargoes,f=>f.platform.order.receivingWarehouseId='31',f=>delete f.merchant.order.receivingWarehouseId]) { const value=dependencyFixture();change(value);assert.throws(()=>buildOrderDependencyFaultPlan(value)) } }) -test('all four dependency-role observations require matching authenticated 503 injection and actual Order S0503 with zero drift',()=>{ +test('all six dependency-role observations require matching authenticated 503 injection and actual Order S0503 with zero drift',()=>{ const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture) - for(const dependency of ['IAM','FINANCE'])for(const role of ['merchant','platform']) { + for(const dependency of ['IAM','FINANCE','WAREHOUSE'])for(const role of ['merchant','platform']) { const evidence=dependencyObservation(plan,fixture,dependency,role) assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,evidence).verified,true) - const wrapped=structuredClone(evidence) - wrapped.response.body.message=plan.phases.find(row=>row.dependency===dependency).expectedMessages[1] - assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,wrapped).verified,true) + const alternative=plan.phases.find(row=>row.dependency===dependency).expectedMessages[1] + if(alternative) { + const wrapped=structuredClone(evidence) + wrapped.response.body.message=alternative + assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,wrapped).verified,true) + } for(const mutate of [ value=>value.response.http=503,value=>value.response.body.code='A0403',value=>value.response.body.message='资格未审核', value=>value.request.body.expectedVersion=5,value=>value.request.headers['Idempotency-Key']='new-key', diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index d43436d7f..a727e8dd2 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -260,12 +260,18 @@ export function buildOrderDependencyFaultPlan(fixture) { initialOrderSha256:createHash('sha256').update(orderPersistenceFacts(detail)).digest('hex') } } if (commands.merchant.orderId === commands.platform.orderId) throw new Error('Use two independent drafts for same-key recovery') - return { marker:fixture.marker, customerId, runtimeChangesExecuted:false, commands, + const receivingWarehouseId = id(fixture.merchant.order.receivingWarehouseId) + if (!/^[1-9]\d*$/.test(receivingWarehouseId) || receivingWarehouseId !== id(fixture.platform.order.receivingWarehouseId)) { + throw new Error('Dependency drafts must reference the same valid public receiving warehouse') + } + return { marker:fixture.marker, customerId, receivingWarehouseId, runtimeChangesExecuted:false, commands, phases:[ { dependency:'IAM', urlProperty:'baiyue.clients.iam.url', targetMethod:'GET', targetPath:`/internal/iam/tenants/${customerId}/order-eligibility`, faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['IAM eligibility service is unavailable','IAM 下单资格响应无效'] }, { dependency:'FINANCE', urlProperty:'baiyue.clients.finance.url', targetMethod:'GET', targetPath:`/internal/finance/v1/wallet/accounts/${customerId}`, faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['Finance account service is unavailable','Finance account response is invalid'] }, + { dependency:'WAREHOUSE', urlProperty:'baiyue.clients.warehouse.url', targetMethod:'GET', targetPath:`/internal/warehouses/0/${receivingWarehouseId}/active`, + faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['主数据服务暂时不可用'] }, ], recovery:'Restore original Order image/config/network identity; submit each original frozen command once, then replay it; require one submission history and no duplicate Firstleg facts.' } } diff --git a/scripts/order-submit-dependency-fault-executor.mjs b/scripts/order-submit-dependency-fault-executor.mjs index d433d6aa2..a347c2ab1 100644 --- a/scripts/order-submit-dependency-fault-executor.mjs +++ b/scripts/order-submit-dependency-fault-executor.mjs @@ -29,17 +29,19 @@ export function scopeFor(plan) { } assert(plan.commands.merchant.orderId !== plan.commands.platform.orderId, 'Two distinct new drafts required') assert(plan.runtimeChangesExecuted === false, 'Only a reviewed unexecuted plan is accepted') - assert(Array.isArray(plan.phases) && plan.phases.length === 2, 'Exact two dependency phases required') + assert(/^[1-9]\d{0,19}$/.test(plan.receivingWarehouseId || ''), 'Bounded public receiving warehouse required') + assert(Array.isArray(plan.phases) && plan.phases.length === 3, 'Exact three dependency phases required') for (const [dependency, property, path, messages] of [ ['IAM','baiyue.clients.iam.url',`/internal/iam/tenants/${plan.customerId}/order-eligibility`,['IAM eligibility service is unavailable','IAM 下单资格响应无效']], ['FINANCE','baiyue.clients.finance.url',`/internal/finance/v1/wallet/accounts/${plan.customerId}`,['Finance account service is unavailable','Finance account response is invalid']], + ['WAREHOUSE','baiyue.clients.warehouse.url',`/internal/warehouses/0/${plan.receivingWarehouseId}/active`,['主数据服务暂时不可用']], ]) { const rows = plan.phases.filter(row => row.dependency === dependency) assert(rows.length === 1 && rows[0].urlProperty === property && rows[0].targetPath === path && rows[0].targetMethod === 'GET' && rows[0].faultHttp === 503 && rows[0].expectedHttp === 200 && rows[0].expectedCode === 'S0503' && JSON.stringify(rows[0].expectedMessages) === JSON.stringify(messages), 'Dependency failure contract may not be weakened') } - return { marker: plan.marker, customerId: plan.customerId, + return { marker: plan.marker, customerId: plan.customerId, receivingWarehouseId: plan.receivingWarehouseId, orderIds: Object.fromEntries(['merchant', 'platform'].map(role => [role, plan.commands[role].orderId])), planSha256: sha(plan) } } @@ -50,7 +52,7 @@ export function renderOrderFaultAction(action, scope, agent, extra = {}) { const routing = extra.routing assert(routing && Object.keys(routing).every(key => ['configurationReviewed','sourceSha256','upstreams','nacos'].includes(key)) && routing.configurationReviewed === true && routing.nacos && Object.keys(routing.nacos).every(key => ['baseUrl','loginPath','configPath'].includes(key)) - && routing.upstreams && Object.keys(routing.upstreams).sort().join(',') === 'FINANCE,IAM', 'Reviewed routing schema only; do not embed credentials') + && routing.upstreams && Object.keys(routing.upstreams).sort().join(',') === 'FINANCE,IAM,WAREHOUSE', 'Reviewed routing schema only; do not embed credentials') for (const address of [routing.nacos.baseUrl,...Object.values(routing.upstreams)]) { const url = new URL(address) assert(['http:','https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash @@ -60,8 +62,9 @@ export function renderOrderFaultAction(action, scope, agent, extra = {}) { && /^[0-9a-f]{64}$/.test(routing.sourceSha256 || ''), 'Reviewed image and Nacos source fingerprint required') } const data = encode(JSON.stringify({ ...extra, action, scope, commonSource, ...(action === 'prepare' ? { proxySource } : {}) })) - const expression = value => '[' + encode(value).match(/.{1,4000}/g).map(part => `'${part}'`).join(',') + "].join('')" - const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${expression(runtime)}),'UTF-8'),'${data}']).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + const literal = encoded => '[' + encoded.match(/.{1,4000}/g).map(part => `'${part}'`).join(',') + "].join('')" + const expression = value => literal(encode(value)) + const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${expression(runtime)}),'UTF-8'),${literal(data)}]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${expression(groovy)}),'UTF-8'),c.channel))` } @@ -78,7 +81,10 @@ export function createOrderFaultRuntime(env, scope, fetcher = globalThis.fetch) }) } catch { throw Error(`RUNTIME_${action.toUpperCase().replaceAll('-', '_')}_OUTCOME_UNKNOWN_USE_STATUS`) } assert(response.ok, `RUNTIME_HTTP_${response.status}_NO_AUTO_RETRY`) - const rows = (await response.text()).split(/\r?\n/).filter(row => row.startsWith('ORDER_DEPENDENCY_JSON=')) + const output = await response.text() + assert(!(output.includes('MultipleCompilationErrorsException') && output.includes('String too long')), + 'RUNTIME_RENDERER_COMPILATION_FAILED_NO_ACTION') + const rows = output.split(/\r?\n/).filter(row => row.startsWith('ORDER_DEPENDENCY_JSON=')) assert(rows.length === 1, 'RUNTIME_RESPONSE_AMBIGUOUS_NO_AUTO_RETRY') const result = JSON.parse(rows[0].slice('ORDER_DEPENDENCY_JSON='.length)) assert(result?.ok === true, /^[A-Z_0-9]{1,90}$/.test(result?.error || '') ? result.error : 'RUNTIME_ACTION_FAILED') @@ -103,6 +109,7 @@ export async function runOrderFaultWindow({ plan, routing, expectedImageId, acti assert(createHash('sha256').update(orderPersistenceFacts(before.detail)).digest('hex') === plan.commands[role].initialOrderSha256 && before.detail.order.status === 'DRAFT' && String(before.detail.order.tenantId) === scope.customerId && String(before.detail.order.id) === scope.orderIds[role] + && String(before.detail.order.receivingWarehouseId) === scope.receivingWarehouseId && ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(before.detail[field])) && before.inboundPageComplete === true, 'PREPARED_DRAFT_CHANGED') } @@ -113,7 +120,7 @@ export async function runOrderFaultWindow({ plan, routing, expectedImageId, acti assert(i < 14, 'PROXY_STARTUP_NOT_READY'); await wait(1000) } await action('activate'); await health() - for (const dependency of ['IAM','FINANCE']) { + for (const dependency of ['IAM','FINANCE','WAREHOUSE']) { for (const role of ['merchant','platform']) { const before = await snapshot(role), command = plan.commands[role] const request = { method: command.method, path: command.path, body: structuredClone(command.body), @@ -138,20 +145,51 @@ export async function runOrderFaultWindow({ plan, routing, expectedImageId, acti && report.restoredRuntime.currentImageId === expectedImageId && report.restoredRuntime.originalFingerprint === report.restoredRuntime.currentFingerprint && report.restoredRuntime.nacosSourcesUnchanged === true - && report.restoredRuntime.upstreamReads?.IAM === true && report.restoredRuntime.upstreamReads?.FINANCE === true, + && report.restoredRuntime.upstreamReads?.IAM === true && report.restoredRuntime.upstreamReads?.FINANCE === true + && report.restoredRuntime.upstreamReads?.WAREHOUSE === true, 'RESTORED_IDENTITY_OR_ROUTES_NOT_PROVED') const businessRecovery = await recovery() report.businessRecovery = businessRecovery report.restoration = await action('finish', { businessRecovery }) assert(report.restoration.restored && report.restoration.proxyStopped && report.restoration.orderRoutesVerified, 'RESTORATION_NOT_COMPLETE') - } catch (error) { report.recoveryError = safeError(error); failure ||= error } + } catch (error) { + report.recoveryError = safeError(error) + if (error.recoveryDiagnostics) report.recoveryDiagnostics = error.recoveryDiagnostics + failure ||= error + } } } - report.success = !failure && report.observations.length === 4 && Boolean(report.restoration?.restored) + report.success = !failure && report.observations.length === 6 && Boolean(report.restoration?.restored) if (failure) report.error = safeError(failure) return report } +export async function waitForRecoveredSubmission({ plan, role, snapshot, wait = pause }) { + const command = plan.commands[role] + let previous, orderFacts + for (let i = 0; i <= 60; i++) { + const current = await snapshot(role), detail = current.detail + assert(current.inboundPageComplete === true && Array.isArray(current.inbounds) && current.inbounds.length <= 1 + && ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail?.[field])) + && String(detail.order.id) === command.orderId && String(detail.order.tenantId) === plan.customerId + && detail.order.status === 'SUBMITTED' && Number(detail.order.version) === command.body.expectedVersion + 1 + && detail.statusHistory.filter(row => row.action === 'SUBMITTED').length === 1 + && detail.statusHistory.filter(row => row.action === 'CREATE').length === 1 + && (current.task === null || current.task && String(current.task.orderId) === command.orderId + && String(current.task.tenantId) === plan.customerId) + && current.inbounds.every(row => String(row.orderId) === command.orderId && row.orderNo === command.orderNo + && String(row.tenantId) === plan.customerId), 'RECOVERY_SUBMISSION_FACTS_INVALID') + const facts = orderPersistenceFacts(detail) + orderFacts ??= facts + assert(facts === orderFacts, 'RECOVERY_ORDER_DRIFT') + const projected = current.inbounds.length === 1 && current.inbounds[0].orderStatusSnapshot === 'SUBMITTED' + if (projected && previous && sha(previous) === sha(current)) return current + previous = projected ? current : undefined + if (i < 60) await wait(500) + } + throw Error('RECOVERY_PROJECTION_TIMEOUT') +} + function businessClient(env, plan, fetcher = globalThis.fetch) { const sessions = {} const call = async (method, path, body, headers = {}, token) => { @@ -191,16 +229,19 @@ function businessClient(env, plan, fetcher = globalThis.fetch) { }, async recovery() { const evidence={} - for(const role of ['merchant','platform']) { - const command=plan.commands[role] - ok(await submit(role,command)) - let before=await snapshot(role) - for(let i=0;i<10;i++) { await pause(500);const next=await snapshot(role);if(sha(before)===sha(next)){before=next;break}before=next } - assert(before.detail.order.status==='SUBMITTED' && before.detail.statusHistory.filter(row=>row.action==='SUBMITTED').length===1 - && before.inbounds.length<=1, 'RECOVERY_SINGLE_SUBMISSION_NOT_PROVED') - ok(await submit(role,command));const after=await snapshot(role) - assert(sha(before)===sha(after),'RECOVERY_REPLAY_DRIFT') - evidence[role]={orderId:command.orderId,status:'SUBMITTED',sameKeyReplayVerified:true,snapshotSha256:sha(after)} + try { + for(const role of ['merchant','platform']) { + const command=plan.commands[role] + ok(await submit(role,command)) + const before=await waitForRecoveredSubmission({plan,role,snapshot}) + ok(await submit(role,command));const after=await snapshot(role) + evidence[role]={orderId:command.orderId,before,after,snapshotSha256:sha(after)} + assert(sha(before)===sha(after),'RECOVERY_REPLAY_DRIFT') + Object.assign(evidence[role],{status:'SUBMITTED',sameKeyReplayVerified:true}) + } + } catch(error) { + error.recoveryDiagnostics=evidence + throw error } return evidence }, @@ -217,7 +258,7 @@ async function main(argv) { for(let i=0;i ({order:{id,tenantId:'20',orderNo:`FL${id}`,version:2,status:'DRAFT'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]}) + const detail = id => ({order:{id,tenantId:'20',receivingWarehouseId:'30',orderNo:`FL${id}`,version:2,status:'DRAFT'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]}) const raw={marker:'TEST-DEPENDENCY',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')} return {raw,plan:buildOrderDependencyFaultPlan(raw)} } const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'never-print-this',FBA_CORE_JENKINS_AGENT:'test-agent'} +function recoverySnapshot(projected=true) { + const {raw,plan}=fixture(),detail=structuredClone(raw.merchant) + detail.order.status='SUBMITTED';detail.order.version=3 + detail.statusHistory=[{action:'CREATE'},{action:'SUBMITTED'}] + return {plan,snapshot:{detail,task:null,inbounds:projected?[{id:'1001',orderId:'101',orderNo:'FL101',tenantId:'20',orderStatusSnapshot:'SUBMITTED'}]:[],inboundPageComplete:true}} +} +test('recovery waits for the actual inbound projection even when two empty reads are stable',async()=>{ + const {plan,snapshot}=recoverySnapshot(),empty=recoverySnapshot(false).snapshot + const values=[empty,empty,snapshot,snapshot];let reads=0 + const value=await waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>values[Math.min(reads++,values.length-1)],wait:async()=>{}}) + assert.equal(reads,4);assert.deepEqual(value,snapshot) +}) +test('missing submission projection cannot pass recovery after the bounded wait',async()=>{ + const {plan,snapshot}=recoverySnapshot(false);let reads=0 + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>{reads++;return snapshot},wait:async()=>{}}),/RECOVERY_PROJECTION_TIMEOUT/) + assert.equal(reads,61) +}) +test('recovery rejects duplicate or foreign projected inbounds instead of waiting them away',async()=>{ + for(const change of [s=>s.inbounds.push({...s.inbounds[0],id:'1002'}),s=>s.inbounds[0].tenantId='21',s=>s.inbounds[0].orderId='102']) { + const {plan,snapshot}=recoverySnapshot();change(snapshot) + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>snapshot,wait:async()=>{}}),/RECOVERY_SUBMISSION_FACTS_INVALID/) + } +}) +test('recovery projection wait rejects order fact drift and incomplete pagination',async()=>{ + const {plan,snapshot}=recoverySnapshot(),changed=structuredClone(snapshot);changed.detail.order.remark='late mutation' + let reads=0 + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>reads++?changed:snapshot,wait:async()=>{}}),/RECOVERY_ORDER_DRIFT/) + const incomplete=structuredClone(snapshot);incomplete.inboundPageComplete=false + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>incomplete,wait:async()=>{}}),/RECOVERY_SUBMISSION_FACTS_INVALID/) +}) + test('runtime classifies dependency HTTP stages without exposing tokens, URLs or response bodies',()=>{ const script=String.raw` import importlib.util,sys,urllib.error @@ -42,6 +73,23 @@ test('renderer binds TEST scope and emits no Jenkins credential; ambiguous remot await assert.rejects(runtime('activate'),/OUTCOME_UNKNOWN_USE_STATUS/) assert.equal(calls,1) }) +test('renderer chunks every embedded Groovy literal and preserves the complete runtime payload',()=>{ + const {plan}=fixture(),scope=scopeFor(plan) + const routing={configurationReviewed:true,nacos:{baseUrl:'http://nacos.invalid:8848',loginPath:'/nacos/v3/auth/user/login',configPath:'/nacos/v3/admin/cs/config'},upstreams:{IAM:'http://iam.invalid:9201',FINANCE:'http://finance.invalid:9203',WAREHOUSE:'http://warehouse.invalid:9205'}} + const rendered=renderOrderFaultAction('preflight',scope,'test-agent',{routing}) + const arrays=text=>Array.from(text.matchAll(/\[('[A-Za-z0-9+/=]+'(?:,'[A-Za-z0-9+/=]+')*)\]\.join\(''\)/g),match=>Array.from(match[1].matchAll(/'([^']+)'/g),part=>part[1])) + const outer=arrays(rendered) + assert.equal(outer.length,1) + const inner=Buffer.from(outer[0].join(''),'base64').toString('utf8') + assert.ok(Array.from(inner.matchAll(/'([^']*)'/g),match=>match[1].length).every(length=>length<=4000)) + const embedded=arrays(inner) + assert.equal(embedded.length,2) + const config=JSON.parse(Buffer.from(embedded[1].join(''),'base64').toString('utf8')) + assert.deepEqual(config.scope,scope) + assert.deepEqual(config.routing,routing) + assert.equal(config.action,'preflight') + assert.ok(Buffer.from(config.commonSource,'base64').length>0) +}) function windowFixture(fault='') { const {raw,plan}=fixture(),calls=[],records=[] @@ -55,7 +103,7 @@ function windowFixture(fault='') { if(action==='health')return {healthy:true} if(action==='mode'){mode=extra.mode;return {mode}} if(action==='records')return {records} - if(action==='verify-restored')return {originalImageId:image,currentImageId:image,originalFingerprint:'same',currentFingerprint:'same',upstreamReads:{IAM:true,FINANCE:true},nacosSourcesUnchanged:true} + if(action==='verify-restored')return {originalImageId:image,currentImageId:image,originalFingerprint:'same',currentFingerprint:'same',upstreamReads:{IAM:true,FINANCE:true,WAREHOUSE:true},nacosSourcesUnchanged:true} if(action==='finish')return {restored:true,healthy:true,proxyStopped:true,orderRoutesVerified:true} return {} }, @@ -72,9 +120,9 @@ function windowFixture(fault='') { return {options,calls} } -test('four observations restore original routes and verify business recovery before stopping proxy',async()=>{ +test('six observations restore original routes and verify business recovery before stopping proxy',async()=>{ const {options,calls}=windowFixture(),report=await runOrderFaultWindow(options) - assert.equal(report.success,true);assert.equal(report.observations.length,4) + assert.equal(report.success,true);assert.equal(report.observations.length,6) assert.ok(calls.indexOf('restore'){ + const {options,calls}=windowFixture() + options.plan.receivingWarehouseId='31' + options.plan.phases[2].targetPath='/internal/warehouses/0/31/active' + const report=await runOrderFaultWindow(options) + assert.equal(report.success,false) + assert.ok(!calls.includes('prepare')) + assert.ok(!calls.includes('activate')) +}) +test('unverified warehouse restoration blocks business recovery and proxy finish',async()=>{ + const {options,calls}=windowFixture(),original=options.action + options.action=async(action,extra)=>{ + const result=await original(action,extra) + if(action==='verify-restored')result.upstreamReads.WAREHOUSE=false + return result + } + const report=await runOrderFaultWindow(options) + assert.equal(report.success,false) + assert.ok(calls.includes('restore')) + assert.ok(!calls.includes('business-recovery')) + assert.ok(!calls.includes('finish')) +}) for(const fault of ['prepare-unknown','restore-unknown','business-recovery-failed'])test(`unsafe cleanup blocks finish: ${fault}`,async()=>{ const {options,calls}=windowFixture(fault),report=await runOrderFaultWindow(options) assert.equal(report.success,false);assert.equal(calls.filter(action=>action==='prepare').length,1) @@ -131,15 +201,37 @@ class Fake: class RuntimeTests(unittest.TestCase): def fixture(self): f=Fake();tools=dict(common);tools.update(inspect=f.inspect,docker=f.docker) - scope={'marker':'OFFLINE','customerId':'7','orderIds':{'merchant':'10','platform':'11'},'planSha256':'b'*64} + scope={'marker':'OFFLINE','customerId':'7','receivingWarehouseId':'30','orderIds':{'merchant':'10','platform':'11'},'planSha256':'b'*64} r=m.Runtime(scope,tools,Path('offline-not-created'));saved=[];controls=[] r.save=lambda state:saved.append(copy.deepcopy(state));r.control=lambda state,mode=None,ttl=0:(controls.append(mode) or {'mode':'PASS'}) - r.probes=lambda state:{'IAM':True,'FINANCE':True} + r.probes=lambda state:{'IAM':True,'FINANCE':True,'WAREHOUSE':True} r.config_fingerprint=lambda container,routing:'reviewed-config' state={'scope':scope,'phase':'PROXY_STARTED','originalId':f.original['Id'],'originalImageId':f.original['Image'], 'originalFingerprint':common['fingerprint'](f.original),'originalBody':common['recreation'](f.original,common['env_of'](f.original),full=True), 'proxyPort':12345,'controlPort':12346,'mutation':None,'routing':{},'routingSourceSha256':'reviewed-config'} return f,r,state,saved,controls + def test_warehouse_probe_requires_the_exact_active_public_domestic_warehouse(self): + import io + from unittest.mock import patch + f,r,s,saved,controls=self.fixture() + state={'internalToken':'never-public','upstreams':{'IAM':'http://iam.invalid','FINANCE':'http://finance.invalid','WAREHOUSE':'http://warehouse.invalid'}} + warehouse={'id':30,'tenantId':0,'status':'ACTIVE','warehouseType':'DOMESTIC','warehouseName':'Scoped receiving warehouse'} + class Response(io.BytesIO):status=200 + def response(request,timeout): + url=request.full_url + if '/internal/iam/' in url:data={'eligible':True} + elif '/internal/finance/' in url:data={'actualBalance':0} + else: + self.assertTrue(url.endswith('/internal/warehouses/0/30/active')) + data={'warehouse':warehouse} + return Response(json.dumps({'code':'200','data':data}).encode()) + with patch.object(m,'http_open',side_effect=response): + self.assertEqual(m.Runtime.probes(r,state),{'IAM':True,'FINANCE':True,'WAREHOUSE':True}) + for field,value in [('id',31),('tenantId',20),('status','DISABLED'),('warehouseType','FBA'),('warehouseName','')]: + original=warehouse[field];warehouse[field]=value + with self.assertRaisesRegex(RuntimeError,'RECEIVING_WAREHOUSE_NOT_READY'):m.Runtime.probes(r,state) + warehouse[field]=original + self.assertEqual(f.calls,[]) def test_original_fingerprint_config_and_image_restored(self): f,r,s,saved,controls=self.fixture();r.activate(s) self.assertTrue(r.matches_injected(f.current,s));self.assertEqual(s['phase'],'INJECTED') @@ -227,5 +319,5 @@ sys.exit(0 if result.wasSuccessful() else 1) fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url)), fileURLToPath(new URL('./test-event-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}) assert.equal(result.status,0,result.stderr || result.error?.message) - assert.match(result.stderr,/Ran 10 tests/) + assert.match(result.stderr,/Ran 11 tests/) }) diff --git a/scripts/order-submit-dependency-fault-proxy.py b/scripts/order-submit-dependency-fault-proxy.py index 240507e8d..94d2a9738 100644 --- a/scripts/order-submit-dependency-fault-proxy.py +++ b/scripts/order-submit-dependency-fault-proxy.py @@ -49,10 +49,14 @@ class Router: customer = config['customerId'] if not re.fullmatch(r'[1-9][0-9]{0,19}', customer): raise ValueError('INVALID_CUSTOMER') + warehouse = config.get('receivingWarehouseId', '') + if not re.fullmatch(r'[1-9][0-9]{0,19}', warehouse): + raise ValueError('INVALID_RECEIVING_WAREHOUSE') if not config.get('requestIds') or any(not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', value) for value in config['requestIds']): raise ValueError('BOUNDED_REQUEST_IDS_REQUIRED') self.paths = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility', - 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer} + 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer, + 'WAREHOUSE': '/internal/warehouses/0/' + warehouse + '/active'} def status(self): with self.lock: @@ -67,7 +71,7 @@ class Router: with self.lock: # Even an authenticated malformed command disarms first. self.mode, self.deadline = 'PASS', 0 - if mode not in ('PASS', 'IAM', 'FINANCE') or (mode != 'PASS' and + if mode not in ('PASS', 'IAM', 'FINANCE', 'WAREHOUSE') or (mode != 'PASS' and (type(ttl) is not int or not 1 <= ttl <= 60)): raise ValueError('INVALID_MODE_OR_TTL') self.mode = mode @@ -77,7 +81,7 @@ class Router: def dispatch(self, method, path, headers, body, peer): prefix, separator, rest = path.lstrip('/').partition('/') - dependency = {'iam': 'IAM', 'finance': 'FINANCE'}.get(prefix) + dependency = {'iam': 'IAM', 'finance': 'FINANCE', 'warehouse': 'WAREHOUSE'}.get(prefix) if not dependency or not separator: return 404, {}, b'Unknown dependency route' actual_path = '/' + rest diff --git a/scripts/order-submit-dependency-fault-proxy.test.mjs b/scripts/order-submit-dependency-fault-proxy.test.mjs index 73b4c64f6..fccf049b0 100644 --- a/scripts/order-submit-dependency-fault-proxy.test.mjs +++ b/scripts/order-submit-dependency-fault-proxy.test.mjs @@ -10,8 +10,8 @@ spec=importlib.util.spec_from_file_location('proxy',sys.argv[1]);p=importlib.uti class ProxyTests(unittest.TestCase): def setUp(self): self.now=[10.0];self.calls=[];self.logs=[] - self.config={'customerId':'7','controlToken':'private-control','internalToken':'private-internal-token', - 'orderIps':['172.20.0.10'],'requestIds':['fixture-request'],'upstreams':{'IAM':'http://iam.invalid:9201','FINANCE':'http://finance.invalid:9207'}} + self.config={'customerId':'7','receivingWarehouseId':'30','controlToken':'private-control','internalToken':'private-internal-token', + 'orderIps':['172.20.0.10'],'requestIds':['fixture-request'],'upstreams':{'IAM':'http://iam.invalid:9201','FINANCE':'http://finance.invalid:9207','WAREHOUSE':'http://warehouse.invalid:9202'}} def forward(*args): self.calls.append(args);return 201,{'X-Upstream':'kept'},b'original-response' self.router=p.Router(self.config,forward=forward,clock=lambda:self.now[0],record=self.logs.append) self.headers={'X-Internal-Service-Token':'private-internal-token','X-Request-Id':'fixture-request'} @@ -42,6 +42,24 @@ class ProxyTests(unittest.TestCase): self.assertEqual(self.send(path)[0],503) self.now[0]=15.0 self.assertEqual(self.send(path)[0],201);self.assertEqual(self.router.status()['mode'],'PASS') + def test_public_warehouse_fault_is_bounded_by_exact_warehouse_request_and_order_identity(self): + self.router.set_mode('private-control','WAREHOUSE',60) + path='/warehouse/internal/warehouses/0/30/active' + self.assertEqual(self.send(path)[0],503) + for other,method,headers,peer in [ + (path.replace('/30/','/31/'),'GET',self.headers,'172.20.0.10'), + (path.replace('/0/','/7/'),'GET',self.headers,'172.20.0.10'), + (path+'?extra=1','GET',self.headers,'172.20.0.10'), + (path,'POST',self.headers,'172.20.0.10'), + (path,'GET',{**self.headers,'X-Request-Id':'another-customer-request'},'172.20.0.10'), + (path,'GET',{**self.headers,'X-Internal-Service-Token':'wrong'},'172.20.0.10'), + (path,'GET',self.headers,'172.20.0.11')]: + self.assertEqual(self.send(other,method,headers,peer)[0],201) + self.now[0]=70.0 + self.assertEqual(self.send(path)[0],201) + def test_invalid_receiving_warehouse_scope_is_rejected(self): + for value in ['0','../30','30?all=true','']: + with self.assertRaises(ValueError):p.Router({**self.config,'receivingWarehouseId':value}) def test_control_auth_and_malformed_authenticated_command(self): self.router.set_mode('private-control','IAM',10) with self.assertRaises(PermissionError):self.router.set_mode('wrong','PASS') @@ -65,5 +83,5 @@ sys.exit(0 if result.wasSuccessful() else 1) fileURLToPath(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url))], { encoding:'utf8', env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'} }) assert.equal(result.status,0,result.stderr || result.error?.message) - assert.match(result.stderr,/Ran 6 tests/) + assert.match(result.stderr,/Ran 8 tests/) }) diff --git a/scripts/order-submit-dependency-fault-runtime.py b/scripts/order-submit-dependency-fault-runtime.py index eb4014ec1..2ed481f55 100644 --- a/scripts/order-submit-dependency-fault-runtime.py +++ b/scripts/order-submit-dependency-fault-runtime.py @@ -24,8 +24,10 @@ from pathlib import Path ORDER = 'firstleg-test-order' ENTRYPOINT = ['sh', '-c', 'exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher'] -PROPERTIES = {'IAM': 'baiyue.clients.iam.url', 'FINANCE': 'baiyue.clients.finance.url'} -UPSTREAM_CONTAINERS = {'IAM': 'firstleg-test-iam', 'FINANCE': 'firstleg-test-finance'} +PROPERTIES = {'IAM': 'baiyue.clients.iam.url', 'FINANCE': 'baiyue.clients.finance.url', + 'WAREHOUSE': 'baiyue.clients.warehouse.url'} +UPSTREAM_CONTAINERS = {'IAM': 'firstleg-test-iam', 'FINANCE': 'firstleg-test-finance', + 'WAREHOUSE': 'firstleg-test-warehouse'} class NoRedirect(urllib.request.HTTPRedirectHandler): @@ -63,10 +65,12 @@ def private_write(path, value): def validate_scope(scope): - if set(scope) != {'marker', 'customerId', 'orderIds', 'planSha256'} or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', scope['marker']): + if set(scope) != {'marker', 'customerId', 'receivingWarehouseId', 'orderIds', 'planSha256'} or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', scope['marker']): raise RuntimeError('INVALID_SCOPE') if not re.fullmatch(r'[1-9][0-9]{0,19}', scope['customerId']) or set(scope['orderIds']) != {'merchant', 'platform'}: raise RuntimeError('INVALID_SCOPE') + if not re.fullmatch(r'[1-9][0-9]{0,19}', scope['receivingWarehouseId']): + raise RuntimeError('INVALID_RECEIVING_WAREHOUSE') if any(not re.fullmatch(r'[1-9][0-9]{0,19}', value) for value in scope['orderIds'].values()) or len(set(scope['orderIds'].values())) != 2: raise RuntimeError('TWO_DISTINCT_ORDERS_REQUIRED') if not re.fullmatch(r'[0-9a-f]{64}', scope['planSha256']): raise RuntimeError('INVALID_PLAN_HASH') @@ -125,7 +129,8 @@ class Runtime: """Real authenticated reads, not manufactured business responses. No body/secret is returned.""" customer = self.scope['customerId']; results = {} targets = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility', - 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer} + 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer, + 'WAREHOUSE': '/internal/warehouses/0/' + self.scope['receivingWarehouseId'] + '/active'} for dependency, path in targets.items(): req = urllib.request.Request(state['upstreams'][dependency] + path, headers={'X-Internal-Service-Token': state['internalToken']}) @@ -136,6 +141,12 @@ class Runtime: if dependency == 'IAM' and data.get('eligible') is not True: raise RuntimeError('CUSTOMER_NOT_ELIGIBLE') if dependency == 'FINANCE' and (data.get('actualBalance') is None or float(data['actualBalance']) < 0): raise RuntimeError('CUSTOMER_BALANCE_NOT_READY') + if dependency == 'WAREHOUSE': + warehouse = data.get('warehouse') or {} + if (str(warehouse.get('id')) != self.scope['receivingWarehouseId'] + or str(warehouse.get('tenantId')) != '0' or warehouse.get('status') != 'ACTIVE' + or warehouse.get('warehouseType') != 'DOMESTIC' or not warehouse.get('warehouseName')): + raise RuntimeError('RECEIVING_WAREHOUSE_NOT_READY') results[dependency] = True return results @@ -229,8 +240,9 @@ class Runtime: proxy = self.directory / 'proxy.py'; proxy.write_bytes(base64.b64decode(config['proxySource'])); os.chmod(proxy, 0o600) private_write(self.directory / 'proxy-config.json', {k: state[k] for k in ['internalToken', 'controlToken', 'proxyPort', 'controlPort', 'upstreams', 'orderIps']} | {'customerId': self.scope['customerId'], + 'receivingWarehouseId': self.scope['receivingWarehouseId'], 'requestIds': ['dep-' + self.scope['marker'] + '-' + dependency + '-' + role - for dependency in ('IAM', 'FINANCE') for role in ('merchant', 'platform')]}) + for dependency in PROPERTIES for role in ('merchant', 'platform')]}) state['phase'] = 'PROXY_START_INTENT'; state['proxyLaunchIntent'] = True; self.save(state) with open(self.directory / 'proxy-output.log', 'ab') as output: os.chmod(self.directory / 'proxy-output.log', 0o600) @@ -361,7 +373,7 @@ class Runtime: if self.config_fingerprint(current, state['routing']) != state['routingSourceSha256']: raise RuntimeError('NACOS_ROUTE_CONFIGURATION_CHANGED') checks = self.probes(state) - if checks != {'IAM': True, 'FINANCE': True}: raise RuntimeError('RESTORED_ROUTE_PROBE_INVALID') + if checks != {dependency: True for dependency in PROPERTIES}: raise RuntimeError('RESTORED_ROUTE_PROBE_INVALID') state['routeReadsVerified'] = checks; self.save(state) return {'healthy': True, 'originalFingerprint': state['originalFingerprint'], 'currentFingerprint': self.tools['fingerprint'](current), 'originalImageId': state['originalImageId'], 'currentImageId': current['Image'], 'upstreamReads': checks, -- 2.54.0 From 9cd709edee7dd675162be17e80439947a88a6016 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 01:58:02 +0800 Subject: [PATCH 129/176] test(lastleg): verify MySQL hold and full PDA picking races --- scripts/README.md | 3 + scripts/f32-pda-hold-race.mjs | 229 +++++++++++++++++++++++++++++ scripts/f32-pda-hold-race.test.mjs | 85 +++++++++++ 3 files changed, 317 insertions(+) create mode 100644 scripts/f32-pda-hold-race.mjs create mode 100644 scripts/f32-pda-hold-race.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 8da16712e..bec27692f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -424,3 +424,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-executor.mjs` 和 `test-event-fault-runtime.py`,不是额外 CLI 入口。它们仅在显式 `CHONGCHONG_ENV=test` 和已授权环境中运行:在首个本轮计划完成前启动已有 ACK 丢失代理,短暂重建同镜像 Firstleg 容器,保留数据卷、端口和网络身份,只变更事件目标与本次故障退避参数。仅选定的装柜事件被抑制 ACK;其他事件直通。核对 12 次失败、DEAD、受保护原事件 retry、DUPLICATE 及业务订单零新增,结束或失败都恢复原镜像配置并检查健康,随后停止本任务代理。不修改业务 SQL、载荷、事件 ID 或 hash,不重放已发布事件。恢复失败必须保留阻断和任务元数据;独立后端发布或其他环境造数不得与此窗口并行。凭据仅在执行节点内存与权限600的临时恢复文件中使用,恢复完成后移除临时文件中的凭据。离线合同为 `test-event-fault-executor.test.mjs`。 `event-outbox-operations.mjs`:单测试环境的内部运维脚本。只连接显式指定的生产服务地址,不通过公网 Gateway,不自动重试或跟随重定向。凭据只从进程环境 `EVENT_OPERATIONS_TOKEN` 读取,另须 `CHONGCHONG_ENV=test`;不要把真实凭据放入参数或仓库。`--service firstleg|lastleg|finance --base <服务源地址> --entry ` 只读查询状态和摘要,不返回业务负载。追加 `--retry --request-id <稳定UUID> --operator <操作人> --reason <原因> --hash ` 会写入目标测试服务的 Outbox 与审计表,执行前须已有该环境操作授权。仅 DEAD 原事件可重试,事件身份、负载及摘要保持不变;重复提交使用同一个请求编号,参数变化会被拒绝。三个服务须先部署 `event_delivery_retry_audit` 表,未设置至少32字节的运维凭据时入口关闭。脚本将脱敏结果输出到标准输出,不持久化凭据;离线验证为 `node --test scripts/event-outbox-operations.test.mjs`。 + + +`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或四端页面通过。 diff --git a/scripts/f32-pda-hold-race.mjs b/scripts/f32-pda-hold-race.mjs new file mode 100644 index 000000000..6b1d330a8 --- /dev/null +++ b/scripts/f32-pda-hold-race.mjs @@ -0,0 +1,229 @@ +// Explicit TEST inventory race. Imports perform no network or environment reads. +import fs from 'node:fs' +import { createHash } from 'node:crypto' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildEvidenceRemoteScript } from './fba-core-regression-evidence.mjs' +import { warehouseClaim } from './admin-test-env-modules/unified-pda-session-revocation.mjs' + +const demand=(condition,message)=>{if(!condition)throw Error(message)} +const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex') +const pause=ms=>new Promise(done=>setTimeout(done,ms)) +const literal=value=>`CONVERT(0x${Buffer.from(value).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` +const tx=['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;','START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +const parse=raw=>JSON.parse(raw.replace(/([:\[,]\s*)(-?\d{16,})(?=\s*[,}\]])/g,'$1"$2"')) +export function validateRacePlan(p){ + const fields=['purpose','marker','customerId','orderId','warehouseId','balanceId','positionId','pdaMembershipId','dispatchItemId','dispatchNo','tripNo','expectedImage','expectedPackages','pickPackages','pickQuantity'] + demand(p&&Object.keys(p).length===fields.length&&Object.keys(p).every(key=>fields.includes(key)),'INVALID_RACE_SCHEMA') + demand(p?.purpose==='F32_OWNED_PDA_HOLD_RACE'&&/^[A-Za-z0-9_-]{1,64}$/.test(p.marker||''),'INVALID_RACE_PLAN') + for(const key of ['customerId','orderId','warehouseId','balanceId','positionId','pdaMembershipId','dispatchItemId']) + demand(typeof p[key]==='string'&&/^[1-9][0-9]{0,19}$/.test(p[key]),'INVALID_RACE_IDENTIFIER') + demand(/^[A-Za-z0-9_-]{1,64}$/.test(p.dispatchNo||'')&&/^[A-Za-z0-9_-]{1,64}$/.test(p.tripNo||''),'INVALID_RACE_BUSINESS_NUMBER') + demand(/^sha256:[a-f0-9]{64}$/.test(p.expectedImage||'')&&p.expectedPackages===4&&p.pickPackages===1 + &&Number.isSafeInteger(p.pickQuantity)&&p.pickQuantity>0,'INVALID_RACE_BASELINE') + return p +} +const b64=value=>'['+Buffer.from(value).toString('base64').match(/.{1,4000}/g).map(x=>`'${x}'`).join(',')+"].join('')" +export function buildOrderLockLeaseScript(plan,phase,agent){ + const p=validateRacePlan(plan) + demand(['HOLD_FIRST','PICK_FIRST'].includes(phase)&&/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'INVALID_LEASE_SCOPE') + const tag=`F32_${p.marker}_${phase}` + // The only write-capable transaction contains a locking SELECT and ROLLBACK. + // A fixed server-side 12-second lease releases the row even if the observer disconnects. + const sql=`SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('rowId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CONNECTION_ID()) FROM ll_order WHERE id=${p.orderId} AND customer_id=${literal(p.customerId)} AND overseas_warehouse_id=${literal(p.warehouseId)} FOR UPDATE;SELECT JSON_OBJECT('marker','${tag}','leaseElapsed',SLEEP(12));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` + const python=`import json,os,subprocess,urllib.parse,re +try: + c=json.loads(subprocess.check_output(['docker','inspect','firstleg-test-lastleg'],text=True,stderr=subprocess.DEVNULL))[0] + e=dict(x.split('=',1) for x in c['Config']['Env']) + if c['Name']!='/firstleg-test-lastleg' or c['Config'].get('Labels',{}).get('com.docker.compose.project')!='firstleg-test' or e.get('DEPLOY_ENV')!='test' or c['Image']!='${p.expectedImage}' or c['State'].get('Health',{}).get('Status')!='healthy':raise RuntimeError('TEST_RUNTIME_CHANGED') + u=urllib.parse.urlsplit(e['LASTLEG_DB_URL'].removeprefix('jdbc:'));host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname + childEnv=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']) + q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=childEnv,timeout=22) + if q.returncode: + code=re.search(r'ERROR ([0-9]{3,5})',q.stderr);print('F32_LEASE_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) + else: + rows=[json.loads(x) for x in q.stdout.splitlines() if x.strip()] + if len(rows)!=3 or str(rows[0].get('rowId'))!='${p.orderId}' or str(rows[0].get('customerId'))!='${p.customerId}' or rows[-1].get('released')!=True:raise RuntimeError('LEASE_SOURCE_NOT_PROVED') + print('F32_LEASE_JSON='+json.dumps({'ok':True,'rows':rows,'imageId':c['Image']})) +except Exception as error: + print('F32_LEASE_JSON='+json.dumps({'ok':False,'error':str(error) if re.fullmatch('[A-Z_]{1,64}',str(error)) else 'LEASE_FAILED_OR_TIMED_OUT'})) +` + const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${b64(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null||c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${b64(groovy)}),'UTF-8'),c.channel))` +} +export function buildLockEvidenceSql(plan,phase){ + const p=validateRacePlan(plan);demand(['HOLD_FIRST','PICK_FIRST'].includes(phase),'INVALID_LEASE_SCOPE') + const tag=`F32_${p.marker}_${phase}` + return [...tx, + `SELECT JSON_OBJECT('collection','lease','connectionId',t.trx_mysql_thread_id) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_order' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_STATUS='GRANTED' AND l.LOCK_DATA=${literal(p.orderId)} AND t.trx_query LIKE ${literal('%'+tag+'%')};`, + `SELECT JSON_OBJECT('collection','wait','requestingConnectionId',r.trx_mysql_thread_id,'blockingConnectionId',b.trx_mysql_thread_id,'orderId',l.LOCK_DATA) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_order' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA=${literal(p.orderId)};`,'COMMIT;'].join('\n') +} +export function buildStockEvidenceSql(plan){ + const p=validateRacePlan(plan),customer=literal(p.customerId) + return [...tx, + `SELECT JSON_OBJECT('collection','sourceOrder','id',CAST(id AS CHAR),'customerId',customer_id,'warehouseId',overseas_warehouse_id,'picked',COALESCE(picked_package_count,0),'shipped',COALESCE(shipped_package_count,0),'version',version,'detained',detained,'outboundNo',outbound_order_no) FROM ll_order WHERE customer_id=${customer} AND id=${p.orderId};`, + `SELECT JSON_OBJECT('collection','hold','id',CAST(id AS CHAR),'key',idempotency_key,'held',held,'version',order_version) FROM ll_order_hold_history WHERE customer_id=${customer} AND order_id=${p.orderId} ORDER BY id;`, + `SELECT JSON_OBJECT('collection','pick','id',CAST(id AS CHAR),'key',idempotency_key,'packages',packages,'quantity',product_quantity,'positionId',CAST(inventory_position_id AS CHAR)) FROM ll_dispatch_pda_pick WHERE customer_id=${customer} AND dispatch_item_id=${p.dispatchItemId} ORDER BY id;`, + `SELECT JSON_OBJECT('collection','attribution','id',CAST(id AS CHAR),'pickId',CAST(pick_id AS CHAR),'orderId',CAST(order_id AS CHAR),'packages',packages,'quantity',product_quantity) FROM ll_dispatch_pda_pick_order WHERE customer_id=${customer} AND order_id=${p.orderId} ORDER BY id;`, + `SELECT JSON_OBJECT('collection','ledger','id',CAST(id AS CHAR),'key',idempotency_key,'positionId',CAST(position_id AS CHAR),'businessType',business_type,'statusFrom',status_from,'statusTo',status_to,'before',before_packages,'delta',delta_packages,'after',after_packages) FROM ll_inventory_ledger WHERE customer_id=${customer} AND balance_id=${p.balanceId} ORDER BY id;`,'COMMIT;'].join('\n') +} +export function assertContendedRequests(rows,expectedOrderId,firstConnectionId){ + const leases=rows.filter(x=>x.collection==='lease'),waits=rows.filter(x=>x.collection==='wait') + demand(leases.length===1,'LIVE_LEASE_NOT_PROVED') + const ids=[...new Set(waits.map(x=>x.requestingConnectionId))] + demand(Number.isSafeInteger(leases[0].connectionId)&&leases[0].connectionId>0 + &&waits.every(x=>x.orderId===expectedOrderId&&Number.isSafeInteger(x.requestingConnectionId)&&x.requestingConnectionId>0) + &&!ids.includes(leases[0].connectionId),'LOCK_EVIDENCE_SCOPE_INVALID') + demand(ids.length===(firstConnectionId===undefined?1:2)&&waits.some(x=>x.blockingConnectionId===leases[0].connectionId),'EXACT_REQUEST_CONTENTION_NOT_PROVED') + if(firstConnectionId!==undefined)demand(ids.includes(firstConnectionId),'FIRST_REQUEST_NO_LONGER_WAITING') + return {leaseConnectionId:leases[0].connectionId,requestingConnections:ids,waits} +} +function stableSnapshot(x){return digest(x)} + +export function assertRaceInventory(plan,phase,before,after){ + const p=validateRacePlan(plan);demand(['HOLD_FIRST','PICK_FIRST'].includes(phase),'INVALID_RACE_PHASE') + const scanned=phase==='PICK_FIRST'?1:0,b=after.balance,order=after.detail.order + const bucketNames=['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'] + const buckets=x=>bucketNames.reduce((n,k)=>{demand(Number.isSafeInteger(Number(x[k]))&&Number(x[k])>=0,'INVALID_STOCK_BUCKET');return n+Number(x[k])},0) + demand(Number(before.balance.availablePackages)===4&&Number(before.balance.totalPackages)===4 + &&before.positions.length===2&&after.positions.length===2 + &&new Set(after.positions.map(x=>x.id)).size===2&&after.positions.every(x=>before.positions.some(y=>y.id===x.id)) + &&Number(b.availablePackages)===(scanned?3:0)&&Number(b.holdPackages)===(scanned?0:4) + &&Number(b.pickedPackages)===0&&Number(b.allocatedPackages)===0&&Number(b.receivingPackages)===0 + &&Number(b.totalPackages)===4-scanned&&buckets(b)===4-scanned + &&after.positions.reduce((n,x)=>n+buckets(x),0)+scanned===4&&order.detained===!scanned,'RACE_INVENTORY_RESULT_INVALID') + const sources=after.sql.filter(x=>x.collection==='sourceOrder') + demand(sources.length===1&&sources[0].id===p.orderId&&sources[0].customerId===p.customerId&&sources[0].warehouseId===p.warehouseId + &&sources[0].picked===scanned&&sources[0].shipped===0&&!sources[0].outboundNo + &&sources[0].version===Number(order.version)&&sources[0].detained===(scanned?0:1),'RACE_SOURCE_ORDER_COUNTERS_INVALID') + const added=collection=>after.sql.filter(x=>x.collection===collection&&!before.sql.some(y=>y.collection===collection&&y.id===x.id)) + const pickKey=`${p.marker}-${phase}-PICK`,newPicks=added('pick'),attributed=added('attribution'),ledger=added('ledger'),holds=added('hold') + demand(newPicks.length===scanned&&newPicks.every(x=>x.key===pickKey&&x.positionId===p.positionId&&x.packages===1&&x.quantity===p.pickQuantity),'RACE_PICK_FACTS_INVALID') + demand(attributed.length===scanned&&attributed.every(x=>x.orderId===p.orderId&&x.packages===1&&x.quantity===p.pickQuantity + &&newPicks.some(y=>y.id===x.pickId)),'RACE_SOURCE_ATTRIBUTION_INVALID') + demand(ledger.every(x=>Number.isSafeInteger(x.delta)&&x.before+x.delta===x.after),'RACE_LEDGER_ARITHMETIC_INVALID') + if(scanned){ + demand(holds.length===0&&ledger.length===3,'PICK_OR_REJECTED_HOLD_DUPLICATED_FACTS') + for(const [type,from,to,delta] of [['ALLOCATE','AVAILABLE','ALLOCATED',1],['PICK','ALLOCATED','PICKED',1],['SHIP','PICKED','SHIPPED',-1]]){ + const rows=ledger.filter(x=>x.businessType===type) + demand(rows.length===1&&rows[0].positionId===p.positionId&&rows[0].statusFrom===from&&rows[0].statusTo===to&&rows[0].delta===delta + &&rows[0].key.startsWith(`DISPATCH:${p.dispatchNo}:${p.dispatchItemId}:${pickKey}:${type}:`),'RACE_SCAN_LEDGER_INVALID') + } + demand(after.positions.every(x=>Number(x.availablePackages)===Number(before.positions.find(y=>y.id===x.id).availablePackages)-(x.id===p.positionId?1:0) + &&Number(x.holdPackages)===0&&Number(x.pickedPackages)===0&&Number(x.allocatedPackages)===0),'RACE_POSITION_DEDUCTION_INVALID') + }else{ + demand(holds.length===1&&holds[0].key===`${p.marker}-${phase}-HOLD`&&holds[0].held===1 + &&holds[0].version===Number(order.version)&&ledger.length===2,'HOLD_OR_REJECTED_PICK_DUPLICATED_FACTS') + demand(ledger.every(x=>x.businessType==='HOLD'&&x.statusFrom==='AVAILABLE'&&x.statusTo==='HOLD' + &&x.delta===Number(before.positions.find(y=>y.id===x.positionId)?.availablePackages)) + &&new Set(ledger.map(x=>x.positionId)).size===2&&ledger.reduce((n,x)=>n+x.delta,0)===4,'RACE_HOLD_LEDGER_INVALID') + } + return{verified:true,scannedOutPackages:scanned,availablePackages:Number(b.availablePackages),heldPackages:Number(b.holdPackages),pickedBucketPackages:0,sourcePickedPackages:sources[0].picked} +} + +async function main(argv){ + const a={};for(let i=0;i{ + const checkpointFd=fs.openSync(checkpoint,'w',0o600) + try{fs.writeFileSync(checkpointFd,JSON.stringify(report,null,2));fs.fsyncSync(checkpointFd)}finally{fs.closeSync(checkpointFd)} + } + const base=env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,''),agent='java-test-agent';let admin,pda,activationAttempted=false + const call=async(method,path,body,token=admin?.accessToken)=>{ + const entry={method,path:path.split('?')[0],phase:'INTENT'};report.requests.push(entry);save() + let res;try{res=await fetch(base+path,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(token?{Authorization:'Bearer '+token}:{}),...(body===undefined?{}:{'Content-Type':'application/json'})},...(body===undefined?{}:{body:JSON.stringify(body)})})}catch{entry.phase='OUTCOME_UNKNOWN';save();throw Error('API_OUTCOME_UNKNOWN_NO_AUTO_RETRY')} + const payload=parse(await res.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:res.status,code:String(payload.code),requestId:res.headers.get('x-request-id')});save();return{http:res.status,body:payload} + } + const ok=r=>{demand(r.http===200&&String(r.body?.code)==='200','API_SUCCESS_REQUIRED');return r.body.data} + const remote=async(script,prefix)=>{ + const res=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(55000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) + demand(res.ok,'JENKINS_HTTP_FAILURE');const lines=(await res.text()).split(/\r?\n/).filter(x=>x.startsWith(prefix+'='));demand(lines.length===1,'REMOTE_RESULT_AMBIGUOUS');const value=JSON.parse(lines[0].slice(prefix.length+1));demand(value.ok===true,/^[A-Z_0-9]+$/.test(value.error||'')?value.error:'REMOTE_FAILED');return value + } + const sql=async query=>(await remote(buildEvidenceRemoteScript('lastleg',query,agent),'FBA_CORE_JSON')).rows + const snapshot=async()=>{ + const detail=ok(await call('GET',`/platform/v1/last-leg/orders/${p.orderId}?customerId=${p.customerId}`)) + const balance=ok(await call('GET',`/platform/v1/last-leg/inventory/balances/${p.balanceId}?customerId=${p.customerId}`)) + const positions=ok(await call('GET',`/platform/v1/last-leg/inventory/balances/${p.balanceId}/positions?customerId=${p.customerId}`)) + const task=ok(await call('GET',`/pda/v1/last-leg/dispatches/${p.dispatchNo}`,undefined,pda.accessToken)) + demand(String(detail.order.id)===p.orderId&&String(detail.order.customerId)===p.customerId&&String(balance.inboundOrderId)===p.orderId + &&String(balance.customerId)===p.customerId&&String(balance.overseasWarehouseId)===p.warehouseId + &&positions.length===2&&positions.every(x=>String(x.customerId)===p.customerId&&String(x.inboundOrderId)===p.orderId) + &&task.tripNo===p.tripNo&&task.dispatchNo===p.dispatchNo,'SNAPSHOT_SCOPE_INVALID') + return{detail,balance,positions,task,sql:await sql(buildStockEvidenceSql(p))} + } + // Explicit headers for immutable command keys; never mint a different key after an unknown result. + const command=async(type,body,key)=>{ + const path=type==='HOLD'?`/platform/v1/last-leg/orders/${p.orderId}/hold?customerId=${p.customerId}`:`/pda/v1/last-leg/dispatches/${p.dispatchNo}/items/${p.dispatchItemId}/pick` + const token=type==='HOLD'?admin.accessToken:pda.accessToken,method=type==='HOLD'?'PUT':'POST' + const entry={method,path:path.split('?')[0],type,key,body,phase:'INTENT'};report.requests.push(entry);save() + let res;try{res=await fetch(base+path,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Authorization:'Bearer '+token,'Content-Type':'application/json',...(type==='HOLD'?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)})}catch{entry.phase='OUTCOME_UNKNOWN';save();throw Error('COMMAND_OUTCOME_UNKNOWN_NO_AUTO_RETRY')} + const payload=parse(await res.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:res.status,code:String(payload.code),message:payload.message,requestId:res.headers.get('x-request-id')});save();return{http:res.status,body:payload} + } + try{ + admin=ok(await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD},'')) + const users=ok(await call('GET',`/platform/v1/iam/users?page=1&size=20&keyword=${encodeURIComponent(env.PDA_USERNAME)}`)) + const user=users.records.find(x=>String(x.membershipId)===p.pdaMembershipId) + demand(user&&!user.protectedAccount&&user.status==='DISABLED'&&String(user.overseasWarehouseId)===p.warehouseId,'OWN_DISABLED_PDA_REQUIRED') + activationAttempted=true;ok(await call('PUT',`/platform/v1/iam/users/${p.pdaMembershipId}/status`,{status:'ACTIVE'})) + pda=ok(await call('POST','/auth/login',{loginId:env.PDA_USERNAME,password:env.PDA_PASSWORD},''));demand(warehouseClaim(pda.accessToken)===p.warehouseId,'PDA_WAREHOUSE_MISMATCH') + for(const phase of ['HOLD_FIRST','PICK_FIRST']){ + const before=await snapshot(),row=before.detail.order,source=before.sql.find(x=>x.collection==='sourceOrder') + demand(row.currentStatus==='INBOUND_COMPLETED'&&row.detained===false&&source?.id===p.orderId&&source.customerId===p.customerId + &&source.warehouseId===p.warehouseId&&source.version===Number(row.version)&&source.detained===0 + &&source.picked===0&&source.shipped===0&&!source.outboundNo&&Number(before.balance.availablePackages)===4 + &&Number(before.balance.holdPackages)===0&&Number(before.balance.pickedPackages)===0&&before.task.canPick===true,'LEGAL_UNPICKED_SOURCE_REQUIRED') + const evidence={phase,before,commands:{},lockSamples:[]};report.phases.push(evidence);save() + const holdKey=`${p.marker}-${phase}-HOLD`,pickKey=`${p.marker}-${phase}-PICK` + const holdBody={held:true,chargeable:false,reason:`${p.marker} ${phase}`,expectedVersion:Number(row.version)} + const pickBody={positionId:p.positionId,packages:1,productQuantity:p.pickQuantity,idempotencyKey:pickKey,remark:`${p.marker} ${phase}`} + evidence.commands={hold:{key:holdKey,body:holdBody},pick:{key:pickKey,body:pickBody}};save() + const pending=[],leasePromise=remote(buildOrderLockLeaseScript(p,phase,agent),'F32_LEASE_JSON').then(x=>({value:x}),error=>({error:error.message})) + const waits=async count=>{ + for(let i=0;i<12;i++){ + const rows=await sql(buildLockEvidenceSql(p,phase));evidence.lockSamples.push(rows);save() + const leases=rows.filter(x=>x.collection==='lease'),connections=[...new Set(rows.filter(x=>x.collection==='wait').map(x=>x.requestingConnectionId))] + if(leases.length===1&&connections.length===count)return rows + if(connections.length>count)throw Error('FOREIGN_SOURCE_CONTENTION');await pause(100) + }throw Error('EXPECTED_MYSQL_LOCK_WAIT_NOT_OBSERVED') + } + try{ + await waits(0) + const firstType=phase==='HOLD_FIRST'?'HOLD':'PICK',secondType=firstType==='HOLD'?'PICK':'HOLD' + const start=type=>command(type,type==='HOLD'?holdBody:pickBody,type==='HOLD'?holdKey:pickKey).then(value=>({type,value}),error=>({type,error:error.message})) + pending.push(start(firstType));const one=assertContendedRequests(await waits(1),p.orderId);evidence.firstWait=one;save() + pending.push(start(secondType));evidence.twoWaiters=assertContendedRequests(await waits(2),p.orderId,one.requestingConnections[0]);save() + }finally{evidence.lease=await leasePromise;evidence.responses=await Promise.all(pending);save()} + demand(!evidence.lease.error&&evidence.lease.value.rows.at(-1).released===true&&evidence.responses.length===2 + &&evidence.lease.value.rows[0].connectionId===evidence.twoWaiters?.leaseConnectionId + &&evidence.lease.value.rows[1].marker===`F32_${p.marker}_${phase}`,'LEASE_OR_BOTH_REQUESTS_UNPROVED') + const win=evidence.responses.find(x=>x.type===(phase==='HOLD_FIRST'?'HOLD':'PICK')),lose=evidence.responses.find(x=>x!==win) + demand(win&&!win.error&&lose&&!lose.error,'RACE_RESPONSE_UNKNOWN');ok(win.value) + demand(lose.value.http===200&&String(lose.value.body.code)==='A0409','RACE_LOSER_NOT_CONFLICT') + const after=evidence.after=await snapshot(),order=after.detail.order + evidence.inventoryProof=assertRaceInventory(p,phase,before,after) + ok(await command(win.type,win.type==='HOLD'?holdBody:pickBody,win.type==='HOLD'?holdKey:pickKey));const replay=evidence.replay=await snapshot() + demand(stableSnapshot(after)===stableSnapshot(replay),'RACE_REPLAY_DRIFT') + if(phase==='HOLD_FIRST'){ + ok(await command('HOLD',{held:false,chargeable:false,reason:`${p.marker} release own first phase`,expectedVersion:Number(order.version)},`${p.marker}-RELEASE`)) + evidence.restored=await snapshot();demand(evidence.restored.detail.order.detained===false&&Number(evidence.restored.balance.availablePackages)===4&&Number(evidence.restored.balance.holdPackages)===0,'OWN_HOLD_RELEASE_FAILED') + } + evidence.verified=true;save() + } + report.success=report.phases.length===2&&report.phases.every(x=>x.verified) + }catch(error){report.success=false;report.error=/^[A-Z_0-9]+$/.test(error.message)?error.message:'F32_RACE_FAILED_INSPECT_CHECKPOINT'} + finally{ + try{if(pda){ok(await call('POST','/auth/logout',{refreshToken:pda.refreshToken},pda.accessToken));report.pdaSessionClosed=true}}catch{report.pdaSessionClosed=false;report.success=false} + try{if(activationAttempted){const user=ok(await call('PUT',`/platform/v1/iam/users/${p.pdaMembershipId}/status`,{status:'DISABLED'}));report.operatorRestored=user.status==='DISABLED';demand(report.operatorRestored,'PDA_DISABLE_NOT_PROVED')}}catch{report.operatorRestored=false;report.success=false} + try{if(admin){ok(await call('POST','/auth/logout',{refreshToken:admin.refreshToken},admin.accessToken));report.adminSessionClosed=true}}catch{report.adminSessionClosed=false;report.success=false} + fs.writeFileSync(fd,JSON.stringify(report,null,2));fs.fsyncSync(fd);fs.closeSync(fd);save() + } + console.log(JSON.stringify({success:report.success,report:resolve(a.report),phases:report.phases.map(x=>({phase:x.phase,verified:!!x.verified})),error:report.error,operatorRestored:report.operatorRestored})) + if(!report.success)process.exitCode=1 +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(/^[A-Z_0-9]+$/.test(error.message)?error.message:'F32_RACE_ARGUMENT_OR_REPORT_ERROR');process.exitCode=1}) diff --git a/scripts/f32-pda-hold-race.test.mjs b/scripts/f32-pda-hold-race.test.mjs new file mode 100644 index 000000000..6f3413cab --- /dev/null +++ b/scripts/f32-pda-hold-race.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { validateRacePlan,buildOrderLockLeaseScript,buildLockEvidenceSql,buildStockEvidenceSql,assertContendedRequests,assertRaceInventory } from './f32-pda-hold-race.mjs' +import {assertReadOnlySql} from './fba-core-regression-evidence.mjs' +const plan={purpose:'F32_OWNED_PDA_HOLD_RACE',marker:'OFFLINE',customerId:'21',orderId:'22',warehouseId:'23',balanceId:'24',positionId:'25',pdaMembershipId:'26',dispatchItemId:'27',dispatchNo:'DS-OFFLINE',tripNo:'TR-OFFLINE',expectedImage:'sha256:'+'a'.repeat(64),expectedPackages:4,pickPackages:1,pickQuantity:10} +test('explicit owned scope rejects SQL injection, image drift and widened quantities',()=>{ + assert.equal(validateRacePlan(plan),plan) + for(const changed of [{password:'do not persist'},{orderId:'22;DELETE FROM ll_order'},{customerId:21},{positionId:'0'},{expectedImage:'latest'},{marker:'x\';evil'},{expectedPackages:5},{pickPackages:2},{pickQuantity:0}]) + assert.throws(()=>validateRacePlan({...plan,...changed})) + assert.throws(()=>buildOrderLockLeaseScript(plan,'OTHER','agent')) + assert.throws(()=>buildOrderLockLeaseScript(plan,'HOLD_FIRST',"agent');evil")) +}) +test('both evidence queries remain consistent read-only transactions with exact original source filters',()=>{ + for(const phase of ['HOLD_FIRST','PICK_FIRST']){ + const sql=buildLockEvidenceSql(plan,phase) + assert.equal(assertReadOnlySql(sql),sql) + assert.match(sql,/OBJECT_NAME='ll_order'/);assert.match(sql,/INDEX_NAME='PRIMARY'/) + assert.match(sql,/LOCK_DATA=CONVERT\(0x3232/) + } + const sql=buildStockEvidenceSql(plan) + assert.equal(assertReadOnlySql(sql),sql) + assert.match(sql,/dispatch_item_id=27/);assert.match(sql,/order_id=22/);assert.match(sql,/balance_id=24/) +}) +function chunks(source){return Array.from(source.matchAll(/\[('[A-Za-z0-9+/=]+'(?:,'[A-Za-z0-9+/=]+')*)\]\.join\(''\)/g),m=>Array.from(m[1].matchAll(/'([^']+)'/g),x=>x[1]).join(''))} +test('lease has a fixed bounded server sleep, rollback and immutable healthy TEST target; Python compiles offline',()=>{ + const outer=buildOrderLockLeaseScript(plan,'HOLD_FIRST','agent') + const inner=Buffer.from(chunks(outer)[0],'base64').toString('utf8') + const python=Buffer.from(chunks(inner)[0],'base64').toString('utf8') + assert.match(python,/SLEEP\(12\)/);assert.match(python,/ROLLBACK;/);assert.match(python,/timeout=22/) + assert.ok(python.includes("JSON_OBJECT('marker','F32_OFFLINE_HOLD_FIRST'"),'Correlation must survive the MySQL client stripping comments') + assert.match(python,/JSON_EXTRACT\('true','\$'\)/) + assert.match(python,/innodb_lock_wait_timeout=5/);assert.match(python,/FOR UPDATE/) + assert.match(python,/DEPLOY_ENV.*test/);assert.match(python,/MYSQL_PWD=e\['LASTLEG_DB_PASSWORD'\]/) + assert.ok(python.includes(plan.expectedImage));assert.match(python,/Health.*healthy/) + assert.doesNotMatch(python,/INSERT INTO|DELETE FROM|UPDATE ll_order|docker.*(?:stop|restart)/) + const result=spawnSync(process.env.PYTHON||'python',['-c',"import sys;compile(sys.stdin.read(),'','exec')"],{input:python,encoding:'utf8'}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) +const rows=[{collection:'lease',connectionId:100},{collection:'wait',requestingConnectionId:101,blockingConnectionId:100,orderId:'22'}] +function scanSnapshots(){ + const bucket=available=>({availablePackages:available,holdPackages:0,allocatedPackages:0,pickedPackages:0,receivingPackages:0}) + const before={balance:{...bucket(4),totalPackages:4},positions:[{id:'25',...bucket(3)},{id:'28',...bucket(1)}],detail:{order:{detained:false}},sql:[]} + const after={balance:{...bucket(3),totalPackages:3},positions:[{id:'25',...bucket(2)},{id:'28',...bucket(1)}],detail:{order:{detained:false,version:1}},sql:[ + {collection:'sourceOrder',id:'22',customerId:'21',warehouseId:'23',picked:1,shipped:0,detained:0,version:1}, + {collection:'pick',id:'100',key:'OFFLINE-PICK_FIRST-PICK',positionId:'25',packages:1,quantity:10}, + {collection:'attribution',id:'101',pickId:'100',orderId:'22',packages:1,quantity:10}, + ...[['ALLOCATE','AVAILABLE','ALLOCATED',1],['PICK','ALLOCATED','PICKED',1],['SHIP','PICKED','SHIPPED',-1]].map(([businessType,statusFrom,statusTo,delta],i)=>({collection:'ledger',id:String(102+i),key:`DISPATCH:DS-OFFLINE:27:OFFLINE-PICK_FIRST-PICK:${businessType}:operation`,positionId:'25',businessType,statusFrom,statusTo,delta,before:delta>0?0:1,after:delta>0?1:0})), + ]} + return{before,after} +} +test('successful PDA scan accounts for one consumed box and its three immutable ledger steps, without inventing DTO counters',()=>{ + const {before,after}=scanSnapshots() + assert.doesNotThrow(()=>assertRaceInventory(plan,'PICK_FIRST',before,after)) +}) +test('missing/duplicate scan ledger, wrong source and a second deduction cannot pass the inventory proof',()=>{ + for(const change of [x=>x.sql=x.sql.filter(y=>y.businessType!=='SHIP'),x=>x.sql.push({...x.sql.find(y=>y.businessType==='SHIP'),id:'999'}), + x=>x.sql.find(y=>y.collection==='sourceOrder').picked=2,x=>x.sql.find(y=>y.collection==='attribution').orderId='other', + x=>x.balance.availablePackages=2,x=>x.sql.find(y=>y.businessType==='SHIP').positionId='other',x=>x.sql.find(y=>y.businessType==='SHIP').delta=-2]){ + const {before,after}=scanSnapshots();change(after) + assert.throws(()=>assertRaceInventory(plan,'PICK_FIRST',before,after)) + } +}) +test('hold winner moves the exact two positions, with one history and no pick or attribution facts',()=>{ + const {before}=scanSnapshots() + const after={balance:{...before.balance,availablePackages:0,holdPackages:4},positions:before.positions.map(x=>({...x,availablePackages:0,holdPackages:x.availablePackages})), + detail:{order:{detained:true,version:2}},sql:[{collection:'sourceOrder',id:'22',customerId:'21',warehouseId:'23',picked:0,shipped:0,detained:1,version:2}, + {collection:'hold',id:'200',key:'OFFLINE-HOLD_FIRST-HOLD',held:1,version:2}, + ...before.positions.map((x,i)=>({collection:'ledger',id:String(201+i),key:'hold',positionId:x.id,businessType:'HOLD',statusFrom:'AVAILABLE',statusTo:'HOLD',before:0,delta:x.availablePackages,after:x.availablePackages}))]} + assert.equal(assertRaceInventory(plan,'HOLD_FIRST',before,after).heldPackages,4) + for(const change of [x=>x.sql.pop(),x=>x.sql.push({collection:'pick',id:'300',key:'wrong'}),x=>x.sql.find(x=>x.collection==='hold').version=3,x=>x.sql.find(x=>x.collection==='ledger').positionId='other']){ + const changed=structuredClone(after);change(changed) + assert.throws(()=>assertRaceInventory(plan,'HOLD_FIRST',before,changed)) + } +}) +test('concurrency requires a live original row lease, first observed waiter and two distinct simultaneous waiters',()=>{ + assert.deepEqual(assertContendedRequests(rows,'22').requestingConnections,[101]) + const both=[...rows,{collection:'wait',requestingConnectionId:102,blockingConnectionId:100,orderId:'22'}] + assert.deepEqual(assertContendedRequests(both,'22',101).requestingConnections,[101,102]) + assert.throws(()=>assertContendedRequests(rows,'22',101),/EXACT_REQUEST_CONTENTION/) + assert.throws(()=>assertContendedRequests(both,'22',999),/FIRST_REQUEST_NO_LONGER_WAITING/) + assert.throws(()=>assertContendedRequests(both,'other',101),/LOCK_EVIDENCE_SCOPE/) + assert.throws(()=>assertContendedRequests(rows.slice(1),'22'),/LIVE_LEASE/) + assert.throws(()=>assertContendedRequests([...both,{collection:'wait',requestingConnectionId:103,blockingConnectionId:100,orderId:'22'}],'22',101),/EXACT_REQUEST_CONTENTION/) +}) -- 2.54.0 From c46a68e32b31b2038d33c341f5b85999bd289267 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 02:54:52 +0800 Subject: [PATCH 130/176] fix(delivery): project current dispatched cargo instead of whole order SKU quantities --- .../service/DeliveryTransferService.java | 49 +++++++++- .../mapper/DispatchPdaPickOrderMapper.java | 20 ++++ .../openapi/overseas-business-v1.yaml | 19 ++++ .../service/DeliveryTransferServiceTest.java | 95 +++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) 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 06c3ed9aa..7e45cc8d8 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 @@ -122,6 +122,7 @@ public class DeliveryTransferService { @Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderStatusHistoryMapper histories; @Resource private LastlegOrderDetailMapper tailOrderDetails; + @Resource private com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders; @Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelItemMapper relabelItems; @Resource private RelabelApplicationService relabelService; @@ -1354,18 +1355,61 @@ public class DeliveryTransferService { if (delivery == null) throw notFound("Delivery order not found"); List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream() .filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList(); - List orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); + var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream() + .filter(row -> items.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList(); + Set sourceOrderIds = new LinkedHashSet<>(); + items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add); + picked.stream().map(com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku::orderId) + .filter(Objects::nonNull).forEach(sourceOrderIds::add); + List orderIds = List.copyOf(sourceOrderIds); Map ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream() .filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order)); List details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList( new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)); - List skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList(); + List skus = deliveryCargoSkus(items, picked, ordersById, details); return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus, feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), feeCoverageEvidence(customerId, delivery.getOutboundNo(), items)); } + private List deliveryCargoSkus(List items, + List picked, + Map orders, List details) { + List result = new ArrayList<>(); + for (DispatchItem item : items) { + var rows = picked.stream().filter(row -> Objects.equals(item.getId(), row.dispatchItemId())).toList(); + long total = 0; + for (var row : rows) { + if (row.packages() == null || row.packages() <= 0) throw conflict("当前派送拣货数量无效"); + total = Math.addExact(total, row.packages()); + String skuCode = trim(row.skuCode()); + if ("MIXED".equalsIgnoreCase(skuCode)) skuCode = null; + String currentSku = skuCode; + var matching = currentSku == null ? List.of() : details.stream() + .filter(detail -> Objects.equals(row.orderId(), detail.getOrderId()) + && currentSku.equals(detail.getSkuCode())).toList(); + LastlegOrderDetail metadata = matching.size() == 1 ? matching.get(0) : null; + LastlegOrder order = row.orderId() == null ? null : orders.get(row.orderId()); + result.add(new DeliveryResponses.DeliverySku(order == null ? item.getInboundCode() : order.getInboundCode(), + order == null ? null : order.getCustomerName(), firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()), + item.getDeliveryAddress(), metadata == null ? null : metadata.getShipmentNo(), + metadata == null ? null : metadata.getFbaTrackingNo(), skuCode, + skuCode == null ? null : row.productName(), Math.toIntExact(row.packages()), null, null)); + } + long snapshotPackages = value(item.getPackageCount()); + if (total > snapshotPackages) throw conflict("当前派送拣货数量超过冻结排车数量"); + if (total < snapshotPackages) { + // Before picking, only the selected dispatch quantity is known; no SKU allocation is invented. + LastlegOrder order = item.getOrderId() == null ? null : orders.get(item.getOrderId()); + result.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), order == null ? null : order.getCustomerName(), + firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()), item.getDeliveryAddress(), + null, null, null, null, Math.toIntExact(snapshotPackages - total), null, null)); + } + } + return List.copyOf(result); + } + private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List items) { var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo); if (outbound == null || outbound.getFeeCoverageJson() == null) return null; @@ -1439,7 +1483,6 @@ public class DeliveryTransferService { allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4)); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } - private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java index 14571382b..26b78cd40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -11,6 +11,26 @@ import java.util.List; @Mapper public interface DispatchPdaPickOrderMapper extends BaseMapper { + /** Current dispatch pick quantities; mixed/unknown SKU identities remain unknown. */ + @Select(""" + SELECT p.dispatch_item_id AS dispatchItemId,a.order_id AS orderId, + CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END AS skuCode, + CASE WHEN a.receipt_id IS NOT NULL THEN i.product_name_zh ELSE b.product_name_zh END AS productName, + COALESCE(a.packages,p.packages) AS packages + FROM ll_dispatch_pda_task t + JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND BINARY p.customer_id=BINARY t.customer_id + LEFT JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND BINARY a.customer_id=BINARY p.customer_id + LEFT JOIN ll_inbound_stock_receipt i ON i.id=a.receipt_id AND BINARY i.customer_id=BINARY a.customer_id + LEFT JOIN ll_inventory_position ip ON ip.id=p.inventory_position_id AND BINARY ip.customer_id=BINARY p.customer_id + LEFT JOIN ll_inventory_balance b ON b.id=ip.balance_id AND BINARY b.customer_id=BINARY ip.customer_id + WHERE BINARY t.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId} + ORDER BY p.id,a.id + """) + List deliveryPickedSkus(@Param("customerId") String customerId, + @Param("dispatchId") Long dispatchId); + + record DeliveryPickedSku(Long dispatchItemId, Long orderId, String skuCode, String productName, Long packages) {} + @Select("SELECT DISTINCT r.* FROM ll_inventory_position current_position " + "JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " + "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " + 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 4345d8118..d1aea61fb 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1777,6 +1777,25 @@ components: required: [order] properties: order: { $ref: '#/components/schemas/DeliveryOrderRow' } + skus: + type: array + description: 当前派送目的地的本次排车货物投影;数量按冻结排车与本次PDA实拣归属,不返回整单申报箱数。未实拣或MIXED库存没有单SKU归属时保留数量,SKU及品名为null;没有本次SKU测量事实时重量与体积为null,不按比例分摊整单测量。 + items: { $ref: '#/components/schemas/DeliveryCargoSku' } + DeliveryCargoSku: + type: object + required: [packageCount] + properties: + inboundCode: { type: string, nullable: true } + customerName: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + receivingAddress: { type: string, nullable: true } + shipmentNo: { type: string, nullable: true } + fbaTrackingNo: { type: string, nullable: true } + skuCode: { type: string, nullable: true } + productName: { type: string, nullable: true } + packageCount: { type: integer, minimum: 0, description: 本次排车/实拣的箱数,不是整单申报箱数 } + weightKg: { type: number, nullable: true } + volumeCbm: { type: number, nullable: true } ReturnOrderCreate: type: object required: [overseasWarehouseId, needRelabel, idempotencyKey, items] 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 01b81fe0b..5f35f3b55 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 @@ -103,6 +103,7 @@ class DeliveryTransferServiceTest { @Mock LastlegOrderMapper tailOrders; @Mock LastlegOrderStatusHistoryMapper histories; @Mock LastlegOrderDetailMapper tailOrderDetails; + @Mock com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders; @Mock OutboundPdaTaskMapper pdaTasks; @Mock OutboundPdaPickMapper pdaPicks; @Mock InventoryPositionMapper inventoryPositions; @@ -141,6 +142,7 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); + ReflectionTestUtils.setField(service, "dispatchPickOrders", dispatchPickOrders); ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); ReflectionTestUtils.setField(service, "pdaPicks", pdaPicks); ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions); @@ -536,11 +538,17 @@ class DeliveryTransferServiceTest { lax9Item.setOrderId(902L); when(deliveryOrders.findByNo("TENANT-A", "DEL-ONT8")).thenReturn(ont8); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(ont8Item, lax9Item)); + when(dispatchPickOrders.deliveryPickedSkus("TENANT-A", 101L)).thenReturn(List.of( + new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "SKU-A", "A", 12L), + new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(202L, 902L, "SKU-B", "B", 8L))); when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of()); DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8"); assertEquals("ONT8", detail.order().destinationWarehouseCode()); + assertEquals(1, detail.skus().size()); + assertEquals(12, detail.skus().get(0).packageCount()); + assertEquals("SKU-A", detail.skus().get(0).skuCode()); verify(tailOrders).selectBatchIds(List.of(901L)); } @@ -556,6 +564,93 @@ class DeliveryTransferServiceTest { } } + @Test + void redispatchDetailUsesActualTwoMixedBoxesInsteadOfOriginalThreeDeclaredSkuBoxes() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-RETURN"); delivery.setPackageCount(2); + DispatchItem selected = item(201L, 501L, "RETURN-PALLET", "ONT8", "Ontario", "IN-A", 2); + selected.setOrderId(901L); + var order = new com.baiyue.lastleg.entity.LastlegOrder(); + order.setId(901L); order.setCustomerId("TENANT-A"); order.setInboundCode("IN-A"); + var small = new com.baiyue.lastleg.entity.LastlegOrderDetail(); + small.setCustomerId("TENANT-A"); small.setOrderId(901L); small.setSkuCode("SMALL"); small.setPackageCount(2); + var large = new com.baiyue.lastleg.entity.LastlegOrderDetail(); + large.setCustomerId("TENANT-A"); large.setOrderId(901L); large.setSkuCode("LARGE"); large.setPackageCount(1); + when(deliveryOrders.findByNo("TENANT-A", "DEL-RETURN")).thenReturn(delivery); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(selected)); + when(dispatchPickOrders.deliveryPickedSkus("TENANT-A", 101L)).thenReturn(List.of( + new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "MIXED", "SMALL", 2L))); + when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of(order)); + when(tailOrderDetails.selectList(any())).thenReturn(List.of(small, large)); + var detail = service.deliveryDetail("TENANT-A", "DEL-RETURN"); + assertEquals(1, detail.skus().size()); assertEquals(2, detail.skus().get(0).packageCount()); + assertEquals(null, detail.skus().get(0).skuCode()); assertEquals(null, detail.skus().get(0).productName()); + assertEquals(null, detail.skus().get(0).weightKg()); assertEquals(null, detail.skus().get(0).volumeCbm()); + assertEquals("IN-A", detail.skus().get(0).inboundCode()); + } + + @Test + void currentDeliveryKnownSkuUsesOnlyActualPickedQuantityAndKeepsUnpickedSkuUnknown() { + DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 3); + var picked = List.of(new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku( + 201L, 901L, "SMALL", "Actual SMALL", 1L)); + var metadata = new com.baiyue.lastleg.entity.LastlegOrderDetail(); + metadata.setOrderId(901L); metadata.setSkuCode("SMALL"); metadata.setPackageCount(99); + metadata.setShipmentNo("SHIP-A"); metadata.setGrossWeightKg(new BigDecimal("999")); + List rows = ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus", + List.of(selected), picked, Map.of(), List.of(metadata)); + assertEquals(2, rows.size()); assertEquals(1, rows.get(0).packageCount()); + assertEquals("SMALL", rows.get(0).skuCode()); assertEquals("SHIP-A", rows.get(0).shipmentNo()); + assertEquals(null, rows.get(0).weightKg()); assertEquals(2, rows.get(1).packageCount()); + assertEquals(null, rows.get(1).skuCode()); + } + + @Test + void mixedPalletUsesActualAttributionSourceRatherThanPrimaryDispatchOrder() { + DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-B", 3); + var a = new com.baiyue.lastleg.entity.LastlegOrder(); a.setId(901L); a.setInboundCode("IN-A"); + var b = new com.baiyue.lastleg.entity.LastlegOrder(); b.setId(902L); b.setInboundCode("IN-B"); + var picked = List.of( + new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "A-SKU", "A", 2L), + new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 902L, "B-SKU", "B", 1L)); + List rows = ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus", + List.of(selected), picked, Map.of(901L, a, 902L, b), List.of()); + assertEquals(List.of("IN-A", "IN-B"), rows.stream().map(DeliveryResponses.DeliverySku::inboundCode).toList()); + assertEquals(List.of(2, 1), rows.stream().map(DeliveryResponses.DeliverySku::packageCount).toList()); + } + + @Test + void selectedDispatchSnapshotBeforePickingDoesNotInventSkuOrDeclaredMeasurements() { + DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2); + List rows = ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus", + List.of(selected), List.of(), Map.of(), List.of()); + assertEquals(1, rows.size()); assertEquals(2, rows.get(0).packageCount()); + assertEquals(null, rows.get(0).skuCode()); assertEquals(null, rows.get(0).weightKg()); + } + + @Test + void actualPickedQuantityAboveFrozenDispatchOrNonPositiveQuantityIsRejected() { + DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2); + for (long quantity : new long[] {0, -1, 3}) { + var picked = List.of(new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku( + 201L, 901L, "SMALL", "SMALL", quantity)); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus", + List.of(selected), picked, Map.of(), List.of())); + } + } + + @Test + void deliveryPickedSkuQueryIsScopedToActualDispatchAndFrozenReceiptSkuWithoutDeclaredPackageJoin() throws Exception { + var method = com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.class + .getMethod("deliveryPickedSkus", String.class, Long.class); + String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertTrue(sql.contains("t.dispatch_order_id=#{dispatchId}")); + assertTrue(sql.contains("BINARY t.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END")); + assertTrue(sql.contains("COALESCE(a.packages,p.packages)")); + assertTrue(!sql.contains("ll_order_detail")); + } + @Test void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); -- 2.54.0 From 8a735020e3e6974e9f1f483d45b6fff2e8e9f1ad Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 10:32:26 +0800 Subject: [PATCH 131/176] test(fleet): verify two-customer departure rollback in TEST --- scripts/README.md | 2 + scripts/admin-test-env-modules/README.md | 2 +- ...ross-customer-acceptance-contract.test.mjs | 23 +++ .../fleet-cross-customer-acceptance.mjs | 9 +- scripts/fleet-trip-rollback-test-env.mjs | 133 ++++++++++++++++++ scripts/fleet-trip-rollback-test-env.test.mjs | 39 +++++ 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 scripts/fleet-trip-rollback-test-env.mjs create mode 100644 scripts/fleet-trip-rollback-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index bec27692f..77239ce4d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -36,6 +36,8 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ ## 验收证据校验 +`fleet-trip-rollback-test-env.mjs` 是跨客户整车发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱来货/实收/排车链,全部合法拣货和理货审核后,在第二客户原托盘锁行建立65秒SELECT FOR UPDATE租约,以默认50秒锁等待超时使原整车发车事务失败;不修改业务行、Schema、共享参数或镜像。严格证明原等待图及第一客户尚未提交的DEPARTED/OUTBOUNDED/重放记录,再按一致性完整快照证明全部回滚;READ UNCOMMITTED仅用于明确标识的中途观察,不作为最终库存事实。租约自动ROLLBACK并核对无等待,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;失败停止、原现场和请求保留、不自动重造或续跑,finally仅清理自有PDA/会话。共享TEST窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 + `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,默认要求全新批次;仅下述精确候选失败检查点允许显式恢复原样本。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 471a51268..02eb06ead 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -14,7 +14,7 @@ `fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 -`fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁证据与事务中途故障回滚严格区分,后者保持 `NOT_TESTED`。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 +`fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 `TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 0ac813278..4361d3982 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -78,6 +78,29 @@ test('current DTO-shaped two-customer trip rejects unready gate, departs once an assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken)) assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken)) }) +test('explicit rollback verifier runs after both picks and before successful departure',async()=>{ + const m=model();let calls=0 + const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, + verifyDepartureRollback:async({ready,departPath,departBody})=>{ + calls++;assert.equal(ready.trip.trip.status,'PLANNED') + assert.ok(ready.members.every(row=>row.dispatch.status==='READY_TO_DEPART')) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) + assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.equal(departBody.idempotencyKey,'RUN-CROSS-DEPART') + return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} + }}) + assert.equal(calls,1);assert.equal(proof.midTransactionRollback,'VERIFIED') +}) +for(const failure of ['unverified','throws','mutates'])test(`rollback verification ${failure} prevents successful departure`,async()=>{ + const m=model() + await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, + verifyDepartureRollback:async()=>{ + if(failure==='throws')throw Error('Observed rollback failed') + if(failure==='mutates')m.state[0].stock.balances[0].availablePackages='-1' + return {verified:failure!=='unverified',scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} + }})) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) + assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY')) +}) for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both']) { test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{ const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}), diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs index 999aacb69..9d7bc0a79 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -33,7 +33,7 @@ export const fixtureContract = Object.freeze({ /** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution. * Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys. */ -export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory } = {}) { +export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback } = {}) { if (!execute) return fixtureContract if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage || !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' @@ -159,6 +159,13 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal const ready=await snapshot('READY') const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker} const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart` + if(verifyDepartureRollback!==undefined) { + check('Explicit rollback verifier is callable',typeof verifyDepartureRollback==='function') + const rollback=await verifyDepartureRollback({context,fixture,ready,departPath,departBody,snapshot}) + check('Mid-transaction rollback has exact owned verification',rollback?.verified===true && rollback.scope==='OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') + equal(ready,await snapshot('ROLLBACK_RESTORED'),'Mid-transaction rollback restores both complete customer execution and inventory snapshots') + proof.rollbackEvidence=rollback;proof.midTransactionRollback='VERIFIED' + } const departed=await request('POST',departPath,departBody,'Depart whole trip once',true) check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2 && canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort())) diff --git a/scripts/fleet-trip-rollback-test-env.mjs b/scripts/fleet-trip-rollback-test-env.mjs new file mode 100644 index 000000000..f5328d947 --- /dev/null +++ b/scripts/fleet-trip-rollback-test-env.mjs @@ -0,0 +1,133 @@ +// Explicit TEST-only row-lock fault; importing this file performs no I/O. +import fs from 'node:fs' +import { createHash } from 'node:crypto' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' +import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' + +const demand=(v,message)=>{if(!v)throw Error(message)} +const literal=v=>`CONVERT(0x${Buffer.from(v).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` +const encoded=v=>'['+Buffer.from(v).toString('base64').match(/.{1,4000}/g).map(x=>`'${x}'`).join(',')+"].join('')" +const id=v=>String(v??'') +const rr=['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;','START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +const pause=ms=>new Promise(done=>setTimeout(done,ms)) +const canonical=v=>JSON.stringify(sortFields(v)) +function sortFields(v){return Array.isArray(v)?v.map(sortFields):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sortFields(v[k])])):v} +export function validateRollbackScope(s) { + demand(s&&/^[A-Za-z0-9_-]{1,64}$/.test(s.marker||'')&&/^[A-Za-z0-9_-]{1,64}$/.test(s.tripNo||''),'ROLLBACK_SCOPE_REQUIRED') + for(const key of ['firstCustomerId','secondCustomerId','firstDispatchId','secondDispatchId','firstOutboundId','secondPalletId']) + demand(typeof s[key]==='string'&&/^[1-9][0-9]{0,19}$/.test(s[key]),'ROLLBACK_IDENTIFIER_INVALID') + demand(s.firstCustomerId!==s.secondCustomerId&&s.firstDispatchId!==s.secondDispatchId&&/^sha256:[a-f0-9]{64}$/.test(s.expectedImage||''),'ROLLBACK_DISTINCT_CUSTOMERS_AND_RUNTIME_REQUIRED') + demand(/^[A-Za-z0-9_-]{1,64}$/.test(s.firstDispatchNo||''),'ROLLBACK_DISPATCH_NUMBER_INVALID') + return s +} +export function rollbackSql(scope,mode) { + const s=validateRollbackScope(scope),second=`customer_id=${literal(s.secondCustomerId)} AND dispatch_order_id=${s.secondDispatchId} AND warehouse_pallet_id=${s.secondPalletId} AND status='ACTIVE'` + if(mode==='LEASE')return `SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('lockId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CONNECTION_ID()) FROM ll_dispatch_pallet_lock WHERE ${second} FOR UPDATE;SELECT JSON_OBJECT('marker','ROLLBACK_${s.marker}','elapsed',SLEEP(65));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` + const lease=`SELECT JSON_OBJECT('collection','lease','connectionId',t.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_STATUS='GRANTED' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second}) AND t.trx_query LIKE ${literal('%ROLLBACK_'+s.marker+'%')};` + const waits=`SELECT JSON_OBJECT('collection','wait','requester',r.trx_mysql_thread_id,'blocker',b.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second});` + if(mode==='LOCKS')return [...rr,lease,waits,'COMMIT;'].join('\n') + if(mode==='OBSERVE')return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;','START TRANSACTION READ ONLY;',lease,waits, + `SELECT JSON_OBJECT('collection','firstDispatch','id',CAST(id AS CHAR),'status',status,'shipped',shipped_package_count) FROM ll_dispatch_order WHERE id=${s.firstDispatchId} AND customer_id=${literal(s.firstCustomerId)};`, + `SELECT JSON_OBJECT('collection','firstOutbound','id',CAST(id AS CHAR),'status',status) FROM ll_outbound_order WHERE id=${s.firstOutboundId} AND customer_id=${literal(s.firstCustomerId)};`, + `SELECT JSON_OBJECT('collection','firstReplay','count',COUNT(*)) FROM ll_dispatch_pda_operation WHERE customer_id=${literal(s.firstCustomerId)} AND operation_type='DEPART' AND idempotency_key=${literal(s.tripNo+':'+s.firstDispatchNo)};`,'COMMIT;'].join('\n') + if(mode==='RELEASED')return [...rr,lease,waits,'COMMIT;'].join('\n') + throw Error('ROLLBACK_MODE_INVALID') +} +export function buildRollbackRemoteScript(scope,mode,agent) { + demand(['RUNTIME','LEASE','LOCKS','OBSERVE','RELEASED'].includes(mode)&&/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'ROLLBACK_REMOTE_MODE_REQUIRED') + const sql=mode==='RUNTIME'?"SELECT JSON_OBJECT('lockWaitSeconds',@@GLOBAL.innodb_lock_wait_timeout);":rollbackSql(scope,mode) + const imageGuard=mode==='RUNTIME'?'':` or c['Image']!='${scope.expectedImage}'` + const python=`import json,os,subprocess,urllib.parse,re +try: + c=json.loads(subprocess.check_output(['docker','inspect','firstleg-test-lastleg'],text=True,stderr=subprocess.DEVNULL))[0] + e=dict(x.split('=',1) for x in c['Config']['Env']) + if c['Name']!='/firstleg-test-lastleg' or c['Config'].get('Labels',{}).get('com.docker.compose.project')!='firstleg-test' or e.get('DEPLOY_ENV')!='test' or c['State'].get('Health',{}).get('Status')!='healthy'${imageGuard}:raise RuntimeError('TEST_RUNTIME_CHANGED') + u=urllib.parse.urlsplit(e['LASTLEG_DB_URL'].removeprefix('jdbc:'));host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname + q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']),timeout=${mode==='LEASE'?75:25}) + if q.returncode: + code=re.search(r'ERROR ([0-9]{3,5})',q.stderr);print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) + else:print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':True,'imageId':c['Image'],'rows':[json.loads(x) for x in q.stdout.splitlines() if x.strip()]})) +except Exception as error: + print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':str(error) if re.fullmatch('[A-Z_]{1,64}',str(error)) else 'REMOTE_ROLLBACK_FAILED'})) +` + const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${encoded(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null||c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${encoded(groovy)}),'UTF-8'),c.channel))` +} +export function assertMidTransactionObservation(rows) { + const one=name=>rows.filter(x=>x.collection===name) + const lease=one('lease'),wait=one('wait'),dispatch=one('firstDispatch'),outbound=one('firstOutbound'),replay=one('firstReplay') + demand(lease.length===1&&wait.length===1&&wait[0].blocker===lease[0].connectionId&&wait[0].lockId===lease[0].lockId&&wait[0].requester!==wait[0].blocker,'OWNED_LATE_ROW_WAIT_NOT_PROVED') + demand(dispatch.length===1&&dispatch[0].status==='DEPARTED'&&dispatch[0].shipped===1&&outbound.length===1&&outbound[0].status==='OUTBOUNDED'&&replay.length===1&&replay[0].count===1,'FIRST_CUSTOMER_UNCOMMITTED_WRITE_NOT_PROVED') + return {leaseConnectionId:lease[0].connectionId,requestingConnectionId:wait[0].requester,lockId:lease[0].lockId,dirtyObservationOnly:true} +} +async function remote(env,scope,mode) { + const script=buildRollbackRemoteScript(scope,mode,env.FBA_CORE_JENKINS_AGENT) + const response=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(mode==='LEASE'?95000:45000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) + demand(response.ok,'ROLLBACK_EVIDENCE_HTTP_FAILED') + const lines=(await response.text()).split(/\r?\n/).filter(x=>x.startsWith('TRIP_ROLLBACK_JSON='));demand(lines.length===1,'ROLLBACK_REMOTE_RESULT_AMBIGUOUS') + const result=JSON.parse(lines[0].slice('TRIP_ROLLBACK_JSON='.length));demand(result.ok===true,/^[A-Z_0-9]+$/.test(result.error||'')?result.error:'ROLLBACK_REMOTE_FAILED') + return result +} +export function createDepartureRollbackVerifier(env,config,runtime) { + return async({context,fixture,ready,departPath,departBody,snapshot})=>{ + const output=resolve('reports',`fleet-cross-${config.runId}`,'rollback.json'),fd=fs.openSync(output,'wx',0o600) + const report={verified:false,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',startedAt:new Date().toISOString(),samples:[],command:{path:departPath,body:departBody},runtime} + const save=()=>{fs.ftruncateSync(fd,0);fs.writeSync(fd,JSON.stringify(report,null,2),0,'utf8');fs.fsyncSync(fd)} + let leasePromise,pending + try { + demand(ready.trip.dispatches?.length===2&&fixture.members.length===2,'TWO_CUSTOMER_READY_SCOPE_REQUIRED') + const [first,second]=ready.trip.dispatches.map(binding=>fixture.members.find(x=>x.dispatchNo===binding.dispatchNo)) + demand(first&&second&&first.customerId!==second.customerId&&first.dispatchNo===fixture.members[0].dispatchNo,'CURRENT_DEPARTURE_ORDER_REQUIRED') + const firstState=ready.members[fixture.members.indexOf(first)],secondState=ready.members[fixture.members.indexOf(second)] + const scope=report.target=validateRollbackScope({marker:context.marker,tripNo:fixture.tripNo,expectedImage:runtime.imageId, + firstCustomerId:first.customerId,secondCustomerId:second.customerId,firstDispatchId:id(firstState.dispatch.id),secondDispatchId:id(secondState.dispatch.id), + firstOutboundId:id(firstState.outbound.outbound.id),secondPalletId:second.palletId,firstDispatchNo:first.dispatchNo}) + save();leasePromise=remote(env,scope,'LEASE').then(value=>({value}),error=>({error:error.message})) + for(let i=0;i<12;i++) { + const rows=(await remote(env,scope,'LOCKS')).rows;report.samples.push({phase:'LEASE',rows});save() + if(rows.filter(x=>x.collection==='lease').length===1)break + demand(i!==11,'LIVE_LEASE_NOT_OBSERVED');await pause(250) + } + pending=context.request('POST',departPath,departBody,fixture.pdaToken).then(value=>({value}),error=>({error:error.message})) + for(let i=0;i<20;i++) { + const rows=(await remote(env,scope,'OBSERVE')).rows;report.samples.push({phase:'MID_TRANSACTION',rows});save() + if(rows.some(x=>x.collection==='wait')) {report.observation=assertMidTransactionObservation(rows);save();break} + demand(i!==19,'MID_TRANSACTION_NOT_OBSERVED');await pause(250) + } + report.response=await pending;save() + demand(!report.response.error,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') + const response=report.response.value + demand(response.http===500||(response.http===200&&String(response.body?.code)==='S0500'),'EXPECTED_DATABASE_FAILURE_RESPONSE_REQUIRED') + report.lease=await leasePromise;save() + demand(!report.lease.error&&report.lease.value.rows.at(-1)?.released===true,'LEASE_RELEASE_NOT_PROVED') + const released=report.released=(await remote(env,scope,'RELEASED')).rows + demand(released.length===0,'OWNED_ROW_LOCK_OR_WAITER_REMAINS') + const restored=await snapshot('ROLLBACK_AFTER_FAULT') + report.consistentSnapshot={beforeSha256:createHash('sha256').update(canonical(ready)).digest('hex'),afterSha256:createHash('sha256').update(canonical(restored)).digest('hex')} + demand(report.consistentSnapshot.beforeSha256===report.consistentSnapshot.afterSha256,'FULL_TWO_CUSTOMER_ROLLBACK_SNAPSHOT_CHANGED') + // The caller also checks restoration before sending the same original command. + report.verified=true;return report + }catch(error){report.error=error.message;throw error} + finally { + if(pending&&!report.response)report.response=await pending + if(leasePromise&&!report.lease)report.lease=await leasePromise + report.finishedAt=new Date().toISOString();save();fs.closeSync(fd) + } + } +} +export async function runTripRollback(config,{env=process.env}={}) { + if(!config.execute)return {...config,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',fault:'65-second SELECT FOR UPDATE lease on the second customer pallet-lock row; first customer writes observed as uncommitted, default 50-second lock timeout rolls back the entire original request; then same original command and full normal chain',businessSqlWrites:false,deployment:false} + demand(env.CHONGCHONG_ENV==='test'&&['JENKINS_URL','JENKINS_USER','JENKINS_API_TOKEN','FBA_CORE_JENKINS_AGENT'].every(key=>env[key]),'EXPLICIT_TEST_EVIDENCE_PROFILE_REQUIRED') + const runtime=await remote(env,{},'RUNTIME');demand(runtime.rows.length===1&&runtime.rows[0].lockWaitSeconds===50,'DEFAULT_50_SECOND_LOCK_TIMEOUT_REQUIRED') + const sources=['scripts/fleet-trip-rollback-test-env.mjs','scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs'].map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) + return runCrossCustomer(config,{env,verify:async(context,fixture,options)=>{ + fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'rollback-source.json'),JSON.stringify({runtime,sources},null,2),{flag:'wx'}) + return verifyFleetCrossCustomer(context,fixture,{...options,verifyDepartureRollback:createDepartureRollbackVerifier(env,config,runtime)}) + }}) +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) { + try {const config=parseCrossCustomerArguments(process.argv.slice(2)),result=await runTripRollback(config);console.log(JSON.stringify(config.execute?{success:result.success,error:result.error,summary:result.summary,requests:result.requestCount,goalComplete:false}:result,null,2));if(config.execute&&!result.success)process.exitCode=1} + catch(error){console.error(/^[A-Z_0-9]+$/.test(error.message)?error.message:'ROLLBACK_ENTRY_FAILED');process.exitCode=1} +} diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs new file mode 100644 index 000000000..fbfb203be --- /dev/null +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback } from './fleet-trip-rollback-test-env.mjs' +const scope={marker:'OWN_RUN',tripNo:'TR-OWN',firstCustomerId:'101',secondCustomerId:'102',firstDispatchId:'201',secondDispatchId:'202',firstOutboundId:'301',secondPalletId:'401',firstDispatchNo:'DS-OWN',expectedImage:'sha256:'+'a'.repeat(64)} +const rows=()=>[{collection:'lease',connectionId:11,lockId:'99'},{collection:'wait',requester:12,blocker:11,lockId:'99'}, + {collection:'firstDispatch',id:'201',status:'DEPARTED',shipped:1},{collection:'firstOutbound',id:'301',status:'OUTBOUNDED'},{collection:'firstReplay',count:1}] +test('plan and import are network-free; execution rejects missing TEST profile before any network',async()=>{ + const plan=await runTripRollback({execute:false},{env:{}}) + assert.equal(plan.businessSqlWrites,false);assert.equal(plan.deployment,false) + await assert.rejects(runTripRollback({execute:true},{env:{CHONGCHONG_ENV:'prod'}}),/TEST/) +}) +test('scope rejects reused customers, injection and unpinned runtime',()=>{ + assert.equal(validateRollbackScope(scope),scope) + for(const changed of [{secondCustomerId:'101'},{secondDispatchId:'201'},{firstDispatchId:'1 OR 1=1'},{marker:"RUN';DELETE"},{expectedImage:'latest'}])assert.throws(()=>validateRollbackScope({...scope,...changed})) +}) +test('lease contains only owned locking reads, fixed duration and rollback, without mutation or schema change',()=>{ + const sql=rollbackSql(scope,'LEASE') + assert.match(sql,/dispatch_order_id=202/);assert.match(sql,/warehouse_pallet_id=401/);assert.match(sql,/FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/) + assert.doesNotMatch(sql,/\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b/) + assert.equal((sql.match(/UPDATE/g)||[]).length,1) + assert.throws(()=>rollbackSql(scope,'CUSTOM_SQL')) + assert.throws(()=>buildRollbackRemoteScript(scope,'LEASE',"agent');evil()")) +}) +test('dirty observation is separate from final consistent cleanup evidence',()=>{ + assert.match(rollbackSql(scope,'OBSERVE'),/READ UNCOMMITTED/) + assert.match(rollbackSql(scope,'RELEASED'),/REPEATABLE READ/) + assert.match(rollbackSql(scope,'RELEASED'),/READ ONLY/) + assert.equal(assertMidTransactionObservation(rows()).dirtyObservationOnly,true) +}) +for(const fault of ['foreign blocker','no wait','two waiters','not yet written','no outbound write','no replay write'])test(`refuses indirect mid-transaction evidence: ${fault}`,()=>{ + const value=rows() + if(fault==='foreign blocker')value[1].blocker=77 + if(fault==='no wait')value.splice(1,1) + if(fault==='two waiters')value.push({...value[1],requester:13}) + if(fault==='not yet written')value[2].status='READY_TO_DEPART' + if(fault==='no outbound write')value[3].status='WAIT_OUTBOUND' + if(fault==='no replay write')value[4].count=0 + assert.throws(()=>assertMidTransactionObservation(value)) +}) -- 2.54.0 From ba4179b28c88542c452ac7993064701a442da10a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 10:37:43 +0800 Subject: [PATCH 132/176] fix(test): wait for database timeout before judging fleet rollback --- scripts/fleet-trip-rollback-test-env.mjs | 13 +++++++++---- scripts/fleet-trip-rollback-test-env.test.mjs | 8 +++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/fleet-trip-rollback-test-env.mjs b/scripts/fleet-trip-rollback-test-env.mjs index f5328d947..ec436a09b 100644 --- a/scripts/fleet-trip-rollback-test-env.mjs +++ b/scripts/fleet-trip-rollback-test-env.mjs @@ -62,6 +62,11 @@ export function assertMidTransactionObservation(rows) { demand(dispatch.length===1&&dispatch[0].status==='DEPARTED'&&dispatch[0].shipped===1&&outbound.length===1&&outbound[0].status==='OUTBOUNDED'&&replay.length===1&&replay[0].count===1,'FIRST_CUSTOMER_UNCOMMITTED_WRITE_NOT_PROVED') return {leaseConnectionId:lease[0].connectionId,requestingConnectionId:wait[0].requester,lockId:lease[0].lockId,dirtyObservationOnly:true} } +export function requestRollbackDeparture(context,path,body,token) { + // The normal JSON request stops at 30 seconds. This deliberate 50-second + // database timeout needs the existing 60-second, single-attempt request path. + return context.requestWithBody('POST',path,body,token) +} async function remote(env,scope,mode) { const script=buildRollbackRemoteScript(scope,mode,env.FBA_CORE_JENKINS_AGENT) const response=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(mode==='LEASE'?95000:45000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) @@ -90,16 +95,13 @@ export function createDepartureRollbackVerifier(env,config,runtime) { if(rows.filter(x=>x.collection==='lease').length===1)break demand(i!==11,'LIVE_LEASE_NOT_OBSERVED');await pause(250) } - pending=context.request('POST',departPath,departBody,fixture.pdaToken).then(value=>({value}),error=>({error:error.message})) + pending=requestRollbackDeparture(context,departPath,departBody,fixture.pdaToken).then(value=>({value}),error=>({error:error.message})) for(let i=0;i<20;i++) { const rows=(await remote(env,scope,'OBSERVE')).rows;report.samples.push({phase:'MID_TRANSACTION',rows});save() if(rows.some(x=>x.collection==='wait')) {report.observation=assertMidTransactionObservation(rows);save();break} demand(i!==19,'MID_TRANSACTION_NOT_OBSERVED');await pause(250) } report.response=await pending;save() - demand(!report.response.error,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') - const response=report.response.value - demand(response.http===500||(response.http===200&&String(response.body?.code)==='S0500'),'EXPECTED_DATABASE_FAILURE_RESPONSE_REQUIRED') report.lease=await leasePromise;save() demand(!report.lease.error&&report.lease.value.rows.at(-1)?.released===true,'LEASE_RELEASE_NOT_PROVED') const released=report.released=(await remote(env,scope,'RELEASED')).rows @@ -107,6 +109,9 @@ export function createDepartureRollbackVerifier(env,config,runtime) { const restored=await snapshot('ROLLBACK_AFTER_FAULT') report.consistentSnapshot={beforeSha256:createHash('sha256').update(canonical(ready)).digest('hex'),afterSha256:createHash('sha256').update(canonical(restored)).digest('hex')} demand(report.consistentSnapshot.beforeSha256===report.consistentSnapshot.afterSha256,'FULL_TWO_CUSTOMER_ROLLBACK_SNAPSHOT_CHANGED') + demand(!report.response.error&&report.response.value?.http!==0,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') + const response=report.response.value + demand(response.http===500||(response.http===200&&String(response.body?.code)==='S0500'),'EXPECTED_DATABASE_FAILURE_RESPONSE_REQUIRED') // The caller also checks restoration before sending the same original command. report.verified=true;return report }catch(error){report.error=error.message;throw error} diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs index fbfb203be..bcd3bb62a 100644 --- a/scripts/fleet-trip-rollback-test-env.test.mjs +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -1,9 +1,15 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback } from './fleet-trip-rollback-test-env.mjs' +import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback, requestRollbackDeparture } from './fleet-trip-rollback-test-env.mjs' const scope={marker:'OWN_RUN',tripNo:'TR-OWN',firstCustomerId:'101',secondCustomerId:'102',firstDispatchId:'201',secondDispatchId:'202',firstOutboundId:'301',secondPalletId:'401',firstDispatchNo:'DS-OWN',expectedImage:'sha256:'+'a'.repeat(64)} const rows=()=>[{collection:'lease',connectionId:11,lockId:'99'},{collection:'wait',requester:12,blocker:11,lockId:'99'}, {collection:'firstDispatch',id:'201',status:'DEPARTED',shipped:1},{collection:'firstOutbound',id:'301',status:'OUTBOUNDED'},{collection:'firstReplay',count:1}] +test('fault request allows database timeout and never retries an unknown write',async()=>{ + const body={recordVersion:'11',idempotencyKey:'ORIGINAL'},calls=[] + const context={request:()=>{throw Error('30_SECOND_CHANNEL')},requestWithBody:async(...args)=>{calls.push(args);return {http:0,body:null}}} + assert.equal((await requestRollbackDeparture(context,'/owned/depart',body,'owned-token')).http,0) + assert.deepEqual(calls,[['POST','/owned/depart',body,'owned-token']]) +}) test('plan and import are network-free; execution rejects missing TEST profile before any network',async()=>{ const plan=await runTripRollback({execute:false},{env:{}}) assert.equal(plan.businessSqlWrites,false);assert.equal(plan.deployment,false) -- 2.54.0 From 446fad33d0c9994ac5846cceb1150f305587accc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 10:44:33 +0800 Subject: [PATCH 133/176] fix(test): require precise database lock conflict during rollback --- scripts/fleet-trip-rollback-test-env.mjs | 8 ++++++-- scripts/fleet-trip-rollback-test-env.test.mjs | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/fleet-trip-rollback-test-env.mjs b/scripts/fleet-trip-rollback-test-env.mjs index ec436a09b..51dce2e60 100644 --- a/scripts/fleet-trip-rollback-test-env.mjs +++ b/scripts/fleet-trip-rollback-test-env.mjs @@ -67,6 +67,11 @@ export function requestRollbackDeparture(context,path,body,token) { // database timeout needs the existing 60-second, single-attempt request path. return context.requestWithBody('POST',path,body,token) } +export function assertDatabaseLockConflict(response) { + demand(response?.http===200&&String(response.body?.code)==='A0409' + &&response.body?.message==='资源正在处理中,请稍后重试' + &&response.requestId&&response.traceId,'EXPECTED_DATABASE_LOCK_CONFLICT_REQUIRED') +} async function remote(env,scope,mode) { const script=buildRollbackRemoteScript(scope,mode,env.FBA_CORE_JENKINS_AGENT) const response=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(mode==='LEASE'?95000:45000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) @@ -110,8 +115,7 @@ export function createDepartureRollbackVerifier(env,config,runtime) { report.consistentSnapshot={beforeSha256:createHash('sha256').update(canonical(ready)).digest('hex'),afterSha256:createHash('sha256').update(canonical(restored)).digest('hex')} demand(report.consistentSnapshot.beforeSha256===report.consistentSnapshot.afterSha256,'FULL_TWO_CUSTOMER_ROLLBACK_SNAPSHOT_CHANGED') demand(!report.response.error&&report.response.value?.http!==0,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') - const response=report.response.value - demand(response.http===500||(response.http===200&&String(response.body?.code)==='S0500'),'EXPECTED_DATABASE_FAILURE_RESPONSE_REQUIRED') + assertDatabaseLockConflict(report.response.value) // The caller also checks restoration before sending the same original command. report.verified=true;return report }catch(error){report.error=error.message;throw error} diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs index bcd3bb62a..f8f76d297 100644 --- a/scripts/fleet-trip-rollback-test-env.test.mjs +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback, requestRollbackDeparture } from './fleet-trip-rollback-test-env.mjs' +import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict } from './fleet-trip-rollback-test-env.mjs' const scope={marker:'OWN_RUN',tripNo:'TR-OWN',firstCustomerId:'101',secondCustomerId:'102',firstDispatchId:'201',secondDispatchId:'202',firstOutboundId:'301',secondPalletId:'401',firstDispatchNo:'DS-OWN',expectedImage:'sha256:'+'a'.repeat(64)} const rows=()=>[{collection:'lease',connectionId:11,lockId:'99'},{collection:'wait',requester:12,blocker:11,lockId:'99'}, {collection:'firstDispatch',id:'201',status:'DEPARTED',shipped:1},{collection:'firstOutbound',id:'301',status:'OUTBOUNDED'},{collection:'firstReplay',count:1}] @@ -10,6 +10,12 @@ test('fault request allows database timeout and never retries an unknown write', assert.equal((await requestRollbackDeparture(context,'/owned/depart',body,'owned-token')).http,0) assert.deepEqual(calls,[['POST','/owned/depart',body,'owned-token']]) }) +test('database conflict requires exact handler response and trace, not arbitrary rejection',()=>{ + const response={http:200,body:{code:'A0409',message:'资源正在处理中,请稍后重试'},requestId:'request',traceId:'trace'} + assert.doesNotThrow(()=>assertDatabaseLockConflict(response)) + for(const wrong of [{http:0},{http:500},{body:{code:'S0500',message:'error'}},{body:{code:'A0409',message:'请完成拣货'}},{traceId:null}]) + assert.throws(()=>assertDatabaseLockConflict({...response,...wrong})) +}) test('plan and import are network-free; execution rejects missing TEST profile before any network',async()=>{ const plan=await runTripRollback({execute:false},{env:{}}) assert.equal(plan.businessSqlWrites,false);assert.equal(plan.deployment,false) -- 2.54.0 From dc993c971391447bc5ac32fa7fb29f4f2f6bbf57 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 10:45:05 +0800 Subject: [PATCH 134/176] feat(lastleg): retain receipt and redispatch history on exceptions --- .../dto/ExceptionOrderResponses.java | 7 +- .../service/ExceptionOrderService.java | 40 +++++++++- .../mapper/ReturnStockReceiptMapper.java | 31 ++++++++ .../openapi/overseas-business-v1.yaml | 44 ++++++++++- .../service/ExceptionReturnHistoryTest.java | 76 +++++++++++++++++++ 5 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index f7fa3ddf8..dd2aa3c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -27,7 +27,12 @@ public final class ExceptionOrderResponses { public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, String skuCode, long maxReturnPackages) {} - public record Detail(Row order, List returnSources) {} + public record Detail(Row order, List returnSources, List returnReceipts) {} + public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, + List redispatches) {} + public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages, + List deliveries) {} + public record RelatedDelivery(String deliveryNo, String status) {} public record CommandResult(Long id, String exceptionNo, String status, long version, Long inventoryAdjustmentId, long restoredPackages, LocalDateTime completedAt, boolean replayed) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 3a0594bf1..9a8c9f9a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -83,6 +83,7 @@ public class ExceptionOrderService { @Resource private InventoryCommandService inventoryCommands; @Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { String customerId = customerScope(scopedCustomerId, query.customerId()); @@ -114,7 +115,44 @@ public class ExceptionOrderService { public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); if (order == null) throw notFound("异常订单不存在"); - return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); + return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), + receiptHistory(returnReceipts.history(customerId, order.getId()))); + } + + private List receiptHistory( + List rows) { + var grouped = new LinkedHashMap>(); + for (var item : rows) grouped.computeIfAbsent(item.receiptId(), ignored -> new ArrayList<>()).add(item); + var receipts = new ArrayList(); + for (var batch : grouped.values()) { + var first = batch.get(0); + if (first.receiptId() == null || !StringUtils.hasText(first.receiptNo()) + || first.receivedPackages() == null || first.receivedPackages() <= 0) + throw conflict("退回实收凭据缺少有效业务编号或数量"); + var dispatches = new LinkedHashMap>(); + for (var item : batch) if (StringUtils.hasText(item.dispatchNo())) + dispatches.computeIfAbsent(item.dispatchNo(), ignored -> new ArrayList<>()).add(item); + var redispatches = new ArrayList(); + for (var entry : dispatches.entrySet()) { + var distinctItems = new LinkedHashMap(); + var deliveries = new LinkedHashMap(); + for (var item : entry.getValue()) { + if (item.dispatchItemId() == null || item.selectedPackages() == null || item.selectedPackages() < 0 + || item.pickedPackages() == null || item.pickedPackages() < 0) + throw conflict("退回重派关联缺少有效排车数量"); + distinctItems.putIfAbsent(item.dispatchItemId(), item); + if (StringUtils.hasText(item.deliveryNo())) deliveries.putIfAbsent(item.deliveryNo(), + new ExceptionOrderResponses.RelatedDelivery(item.deliveryNo(), item.deliveryStatus())); + } + long selected = distinctItems.values().stream().mapToLong(item -> item.selectedPackages()).sum(); + long picked = distinctItems.values().stream().mapToLong(item -> item.pickedPackages()).sum(); + redispatches.add(new ExceptionOrderResponses.Redispatch(entry.getKey(), entry.getValue().get(0).dispatchStatus(), + selected, picked, List.copyOf(deliveries.values()))); + } + receipts.add(new ExceptionOrderResponses.ReturnReceipt(first.receiptNo(), first.receivedPackages(), first.receivedAt(), + List.copyOf(redispatches))); + } + return List.copyOf(receipts); } public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java index 7183b318f..deb20a909 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java @@ -4,9 +4,40 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt; import org.apache.ibatis.annotations.*; import java.util.List; +import java.time.LocalDateTime; @Mapper public interface ReturnStockReceiptMapper extends BaseMapper { + @Select(""" + SELECT r.id AS receiptId,r.receipt_no AS receiptNo,r.received_packages AS receivedPackages, + r.received_at AS receivedAt,di.id AS dispatchItemId,d.dispatch_no AS dispatchNo, + d.status AS dispatchStatus,di.package_count AS selectedPackages, + COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a + JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND BINARY p.customer_id=BINARY a.customer_id + WHERE BINARY a.customer_id=BINARY r.customer_id AND a.return_receipt_id=r.id + AND p.dispatch_item_id=di.id),0) AS pickedPackages, + delivery.delivery_no AS deliveryNo,delivery.status AS deliveryStatus + FROM ll_return_stock_receipt r + LEFT JOIN ll_dispatch_item di ON di.return_receipt_id=r.id AND BINARY di.customer_id=BINARY r.customer_id + LEFT JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND BINARY d.customer_id=BINARY r.customer_id + LEFT JOIN ll_delivery_order delivery ON delivery.dispatch_order_id=d.id + AND BINARY delivery.customer_id=BINARY r.customer_id + AND BINARY delivery.delivery_group_key=BINARY CASE + WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL + THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) + WHEN COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')) IS NOT NULL + THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')))) + WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) + ELSE NULL END + WHERE BINARY r.customer_id=BINARY #{customerId} AND r.exception_order_id=#{exceptionId} + ORDER BY r.received_at,r.id,d.id,di.id,delivery.id + """) + List history(@Param("customerId") String customerId, @Param("exceptionId") Long exceptionId); + + record ReturnHistoryRow(Long receiptId, String receiptNo, Long receivedPackages, LocalDateTime receivedAt, + Long dispatchItemId, String dispatchNo, String dispatchStatus, Integer selectedPackages, + Long pickedPackages, String deliveryNo, String deliveryStatus) {} + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id); record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { } 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 d1aea61fb..c0ce7627f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -871,10 +871,10 @@ paths: /api/platform/v1/last-leg/exception-orders/{exceptionNo}: get: summary: 查询异常订单详情与冻结退回明细 - description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。 + description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。returnReceipts 保留当前异常全部实收批次及按冻结退回凭据关联的重派排车/派送,包括已耗尽库存;每批及每次重派分别返回数量,不能把候选库存或可变尾程当前单号当历史关联。 security: [{ bearerAuth: [] }] parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } } + responses: { '200': { description: 异常订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionOrderDetailResponse' } } } }, '404': { description: 异常订单不存在 } } /api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing: post: summary: 开始处理异常订单 @@ -1748,6 +1748,46 @@ paths: responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } components: schemas: + ExceptionOrderDetailResponse: + type: object + properties: + code: { type: string } + data: + type: object + required: [order, returnSources, returnReceipts] + properties: + order: { type: object } + returnSources: { type: array, items: { type: object }, description: 当前异常剩余可收来源,不是实收历史 } + returnReceipts: + type: array + description: 全部真实退回实收批次;无实收时为空数组,不因库存耗尽而隐藏 + items: + type: object + required: [receiptNo, receivedPackages, receivedAt, redispatches] + properties: + receiptNo: { type: string } + receivedPackages: { type: string, pattern: '^[0-9]+$', description: 本批实际实收箱数,Java long按统一JSON合同返回字符串 } + receivedAt: { type: string, format: date-time } + redispatches: + type: array + description: 本凭据关联的全部重派排车;尚未关联时为空数组 + items: + type: object + required: [dispatchNo, status, selectedPackages, pickedPackages, deliveries] + properties: + dispatchNo: { type: string } + status: { type: string } + selectedPackages: { type: string, pattern: '^[0-9]+$', description: 本凭据在该排车冻结选定箱数,按排车项去重 } + pickedPackages: { type: string, pattern: '^[0-9]+$', description: 本凭据在该排车的实际PDA拣货箱数,未拣为0 } + deliveries: + type: array + description: 仅关联该退回排车项目的地的派送,不返回同排车其他目的地单据 + items: + type: object + required: [deliveryNo, status] + properties: + deliveryNo: { type: string } + status: { type: string } DeliveryOrderRow: type: object required: [deliveryNo, packageCount, signedPackages] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java new file mode 100644 index 000000000..c90f3ba17 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper; +import com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnHistoryRow; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ExceptionReturnHistoryTest { + private final ExceptionOrderService service = new ExceptionOrderService(); + private final LocalDateTime time = LocalDateTime.of(2026, 10, 3, 23, 24); + + private List history(List rows) { + return ReflectionTestUtils.invokeMethod(service, "receiptHistory", rows); + } + private ReturnHistoryRow row(long receipt, String dispatch, long item, String delivery, int quantity) { + return new ReturnHistoryRow(receipt, "RSR-" + receipt, 2L, time, item, dispatch, "DEPARTED", + quantity, (long) quantity, delivery, delivery == null ? null : "DELIVERED"); + } + @Test void retainsAllBatchesAndMultipleRedispatchesWithoutMultiplyingItemQuantitiesByDeliveryRows() { + var result = history(List.of(row(1, "DS-A", 11, "DL-A", 1), row(1, "DS-A", 11, "DL-B", 1), + row(1, "DS-B", 12, "DL-C", 1), new ReturnHistoryRow(2L, "RSR-2", 1L, time.plusMinutes(1), + null, null, null, null, 0L, null, null))); + assertEquals(2, result.size()); assertEquals(2, result.get(0).redispatches().size()); + var first = result.get(0).redispatches().get(0); + assertEquals(1, first.selectedPackages()); assertEquals(1, first.pickedPackages()); + assertEquals(List.of("DL-A", "DL-B"), first.deliveries().stream().map(ExceptionOrderResponses.RelatedDelivery::deliveryNo).toList()); + assertEquals(1, result.get(1).receivedPackages()); assertTrue(result.get(1).redispatches().isEmpty()); + } + @Test void sameDispatchAcrossDistinctReceiptBatchesRemainsSeparate() { + var result = history(List.of(row(1, "DS-A", 11, "DL-A", 1), row(2, "DS-A", 12, "DL-A", 1))); + assertEquals(2, result.size()); assertEquals("RSR-1", result.get(0).receiptNo()); + assertEquals("RSR-2", result.get(1).receiptNo()); + assertEquals(1, result.get(0).redispatches().get(0).pickedPackages()); + } + @Test void currentDetailReadsOnlyResolvedCustomerAndExceptionWithoutStateCommands() { + var orders = mock(ExceptionOrderMapper.class); var returns = mock(ExceptionReturnItemMapper.class); + var receipts = mock(ReturnStockReceiptMapper.class); + ReflectionTestUtils.setField(service, "exceptionOrders", orders); + ReflectionTestUtils.setField(service, "returnItems", returns); + ReflectionTestUtils.setField(service, "returnReceipts", receipts); + var order = new ExceptionOrder(); order.setId(10L); order.setCustomerId("C"); + order.setExceptionNo("EX-10"); order.setExceptionType("RETURN"); order.setStatus("COMPLETED"); + when(orders.findByNo("C", "EX-10")).thenReturn(order); + when(returns.findByException("C", 10L)).thenReturn(List.of()); + when(receipts.history("C", 10L)).thenReturn(List.of(row(1, "DS-A", 11, "DL-A", 2))); + var result = service.detail("C", "EX-10"); + assertEquals("COMPLETED", result.order().status()); assertTrue(result.order().allowedActions().isEmpty()); + assertEquals(2, result.returnReceipts().get(0).receivedPackages()); + verify(receipts).history("C", 10L); verify(orders, never()).updateById(any(ExceptionOrder.class)); + assertThrows(ResponseStatusException.class, () -> service.detail("OTHER", "EX-10")); + verify(receipts, never()).history(eq("OTHER"), any()); + } + @Test void historyQueryKeepsConsumedReceiptsAndFiltersDeliveryByCustomerAndItemDestination() throws Exception { + String sql = String.join(" ", ReturnStockReceiptMapper.class.getMethod("history", String.class, Long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertTrue(sql.contains("r.exception_order_id=#{exceptionId}")); + assertTrue(sql.contains("BINARY r.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("BINARY delivery.delivery_group_key=BINARY CASE")); + assertTrue(sql.contains("WHEN di.destination_type='PRIVATE'")); + assertTrue(sql.contains("a.return_receipt_id=r.id")); + assertFalse(sql.contains("available_packages")); assertFalse(sql.contains("ll_order o")); + } + @Test void receiptBusinessNumberAndPositiveReceivedQuantityCannotBeGuessed() { + assertThrows(ResponseStatusException.class, () -> history(List.of(new ReturnHistoryRow(1L, null, 2L, time, + null, null, null, null, 0L, null, null)))); + } +} -- 2.54.0 From 791d1a650f8a801b27c9517ab7bbc0f0a3152db9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 12:07:37 +0800 Subject: [PATCH 135/176] test(fleet): verify same-route costs from three real pallet sources --- scripts/README.md | 2 + scripts/fleet-cross-customer-test-env.mjs | 5 +- .../fleet-cross-customer-test-env.test.mjs | 13 +- scripts/fleet-same-route-cost-test-env.mjs | 120 ++++++++++++++++++ .../fleet-same-route-cost-test-env.test.mjs | 40 ++++++ 5 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 scripts/fleet-same-route-cost-test-env.mjs create mode 100644 scripts/fleet-same-route-cost-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 77239ce4d..7b35824b0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用两新客户普通来货/PDA实收/客户确认,各一托一箱,再复用真实外接预订、来货、PDA实收与关联,三托统一目的站;逐单拣货、实际装载和审核后整车发车。登记USD1.00合成成本,以不可变DEPART实测托位、三票来源及业务号顺序独立核对最大余数分币、原币守恒、创建重放与唯一历史,再逐票签收、正式结案并分别释放车辆/司机。自有两票分别分摊,不能先合并自有托位后假设汇总一定为0.67;余分按现行逐业务号规则分配。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;入口要求验收函数显式返回本轮车次VERIFIED才能结案,不借另一证据对象的状态。失败保留原请求/样本、停止且不自动重跑,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 + `fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 `deploy/scripts/run-ci-maven.sh` 是 Linux Jenkins 的受限 Maven 构建入口,不部署、不访问业务数据库、不安装依赖。总控及单服务 Job 共用 `firstleg-test-maven` 锁;同一构建账号另用 `$HOME/.cache/chongchong-ci/maven.lock` 文件锁,已有本机持锁任务时失败退出,不删除锁文件或杀其他进程。入口禁止 `-T/--threads`,固定 Maven 堆768MiB、元空间256MiB、直接内存128MiB,启用根POM `ci-bounded`:单个测试JVM、堆1GiB、元空间384MiB、直接内存128MiB,保留JaCoCo迟绑定;OOM快速退出,父进程丢失检测、停止时kill、单测试JVM20分钟上限。独立于Jenkins连接的本机GNU timeout在45分钟后TERM、30秒后KILL进程组,流水线另设50分钟超时;缺少flock/timeout/mvn直接失败。构建输出及Surefire报告沿现有Jenkins归档;失败先确认原进程退出再重跑,不自动清理缓存。该预算不等于总RSS硬限额,代理本身和JVM原生内存仍需保留余量;节点执行器应设为1、保持systemd control-group清理,并在真实Linux节点验证峰值。`ci-maven-resource-contract.test.mjs` 检查双入口接线、参数拒绝/失败传播和子JVM配置,Shell替身检查不代替Linux实际锁与信号验收。 diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs index 59894a155..809bd6654 100644 --- a/scripts/fleet-cross-customer-test-env.mjs +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -107,7 +107,8 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot = } const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) save('fixture.json', fixture) - await verify(context, fixture, { execute: true, readInventory }) + const verification = await verify(context, fixture, { execute: true, readInventory }) + if (verification?.status !== 'VERIFIED' || verification.tripNo !== fixture.tripNo) throw Error('Current fixture verification incomplete') const trip = context.createdResources.fleetTrips?.find(row => row.tripNo === fixture.tripNo) if (!trip) throw Error('Original fixture trip missing for resource completion') const beforeClosure = [] @@ -116,7 +117,7 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot = const afterClosure = [] for (const member of fixture.members) afterClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) if (JSON.stringify(beforeClosure) !== JSON.stringify(afterClosure)) throw Error('Trip closure changed customer inventory') - report = { success: context.createdResources.fleetCrossCustomer?.status === 'VERIFIED' + report = { success: verification.status === 'VERIFIED' && trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', closureInventoryUnchanged: true } } catch (error) { report = { success: false, error: error instanceof Error ? error.message : 'CROSS_CUSTOMER_FAILED' } diff --git a/scripts/fleet-cross-customer-test-env.test.mjs b/scripts/fleet-cross-customer-test-env.test.mjs index 019839836..660bc30ba 100644 --- a/scripts/fleet-cross-customer-test-env.test.mjs +++ b/scripts/fleet-cross-customer-test-env.test.mjs @@ -27,8 +27,9 @@ function harness(root, fail = false) { return { pdaToken: options.pdaToken, marker: context.marker, tripNo: 'TR-OFFLINE', members: [{ customerId: '42' }, { customerId: '43' }] } }, verify: async (_context, _fixture, options) => { await options.readInventory({ marker: context.marker, customerId: '42', warehouseId: '900' }) - context.createdResources.fleetCrossCustomer = { status: 'VERIFIED' } + context.createdResources.fleetCrossCustomer = { status: 'VERIFIED', tripNo: 'TR-OFFLINE' } context.createdResources.fleetTrips = [{ tripNo: 'TR-OFFLINE' }] + return context.createdResources.fleetCrossCustomer }, finish: async (_context, _module, trip) => { trip.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, } } @@ -39,6 +40,16 @@ test('plan is network-free and execution requires explicit TEST, unique run and for (const args of [['--execute'], ['--execute', '--environment', 'PROD', '--run-id', 'one', '--warehouse-id', '900'], ['--run-id', '../outside'], ['--plan', '--execute'], ['--warehouse-id', '1 OR 1=1']]) assert.throws(() => parseCrossCustomerArguments(args)) await assert.rejects(runCrossCustomer(config(), { env: { ...env, CHONGCHONG_ENV: 'prod' } }), /TEST/) }) + +for (const result of [undefined,{status:'FAILED',tripNo:'TR-OFFLINE'},{status:'VERIFIED',tripNo:'TR-OTHER'}]) test('unverified or foreign result cannot close fixture resources',async()=>{ + const root=mkdtempSync(join(tmpdir(),'fleet-cross-contract-')) + try { + const h=harness(root);let finished=false + h.options.verify=async()=>result;h.options.finish=async()=>{finished=true} + const report=await runCrossCustomer(config(),h.options) + assert.equal(report.success,false);assert.equal(finished,false);assert.deepEqual(h.cleanup,['81']) + }finally{assert.equal(dirname(resolve(root)),resolve(tmpdir()));assert.ok(basename(root).startsWith('fleet-cross-contract-'));rmSync(root,{recursive:true,force:true})} +}) for (const fail of [false, true]) test(`execution ${fail ? 'failure' : 'success'} preserves evidence, closes session and disables only its operator`, async () => { const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) try { diff --git a/scripts/fleet-same-route-cost-test-env.mjs b/scripts/fleet-same-route-cost-test-env.mjs new file mode 100644 index 000000000..59a212b29 --- /dev/null +++ b/scripts/fleet-same-route-cost-test-env.mjs @@ -0,0 +1,120 @@ +import fs from 'node:fs' +import {createHash} from 'node:crypto' +import {resolve} from 'node:path' +import {pathToFileURL} from 'node:url' +import {parseCrossCustomerArguments,runCrossCustomer} from './fleet-cross-customer-test-env.mjs' +import {controlledPalletMeasurements} from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import {verifyFleetExternalReceipt} from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' +import {verifyFleetExternalDispatch} from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' +import {expectedAllocations} from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' + +const ROOT='/api/platform/v1/last-leg',PDA='/api/pda/v1/last-leg/dispatches',MODULE='fleet-same-route-cost' +const id=v=>String(v??''),demand=(value,message)=>{if(!value)throw Error(message)} +const sorted=v=>Array.isArray(v)?v.map(sorted):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sorted(v[k])])):v +const same=(a,b)=>JSON.stringify(sorted(a))===JSON.stringify(sorted(b)) +const cents=v=>{demand(/^\d+(?:\.\d{1,2})?$/.test(id(v)),'INVALID_MONEY');const [a,b='']=id(v).split('.');return BigInt(a)*100n+BigInt(b.padEnd(2,'0'))} + +export function assertSameRouteBasis(departure,shares) { + const load=departure?.snapshot?.confirmedLoad,stops=departure?.snapshot?.trip?.stops + demand(departure?.action==='DEPART'&&load?.manifestHash,'ACTUAL_DEPART_HISTORY_REQUIRED') + demand(shares?.length===3&&shares.filter(x=>x.sourceType==='OWN').length===2&&shares.filter(x=>x.sourceType==='EXTERNAL').length===1,'THREE_EXACT_COST_SOURCES_REQUIRED') + demand(new Set(shares.map(x=>x.businessNo)).size===3&&new Set(shares.map(x=>x.dispatchNo)).size===3&&shares.every(x=>x.palletSpaces===1&&x.warehousePalletIds?.length===1),'THREE_INDEPENDENT_STANDARD_PALLETS_REQUIRED') + const ids=shares.flatMap(x=>x.warehousePalletIds).map(id) + demand(new Set(ids).size===3&&load?.pallets?.length===3&&same([...ids].sort(),load.pallets.map(x=>id(x.warehousePalletId)).sort()),'COMPLETE_DEPART_PALLET_SCOPE_REQUIRED') + demand(load.cargo?.length===3&&new Set(load.pallets.map(x=>x.deliveryIndex)).size===1&&load.pallets.every(x=>x.deliveryIndex>0&&stops?.[x.deliveryIndex]?.destinationKey),'SINGLE_REAL_UNLOADING_STOP_REQUIRED') + for(const share of shares){ + const pallet=load.pallets.find(x=>id(x.warehousePalletId)===id(share.warehousePalletIds[0])),cargo=load.cargo.filter(x=>id(x.warehousePalletId)===id(pallet.warehousePalletId)) + demand(Number(pallet.palletSpaces)===1&&cargo.length===1&&cargo[0].dispatchNo===share.dispatchNo&&id(cargo[0].customerId)===share.customerId&&Number(cargo[0].packages)===1&&cargo[0].destinationKey===stops[pallet.deliveryIndex].destinationKey,'SOURCE_AND_DESTINATION_MISMATCH') + } + const externals=(departure.snapshot.externalBookings||[]).filter(x=>!['DRAFT','CANCELLED'].includes(x.status)),external=shares.find(x=>x.sourceType==='EXTERNAL'),booking=externals[0] + demand(externals.length===1&&booking?.status==='BOUND'&&booking.bookingNo===external.businessNo&&booking.dispatchNo===external.dispatchNo&&id(booking.customerId)===external.customerId&&Number(booking.receipt?.source?.receivedPackages)===1&&booking.receipt.source.pallets?.length===1&&Number(booking.receipt.source.pallets[0].packages)===1&&id(booking.receipt.source.pallets[0].warehousePalletId)===id(external.warehousePalletIds[0]),'ACTUAL_EXTERNAL_RECEIPT_REQUIRED') + demand(shares.filter(x=>x.sourceType==='OWN').every(x=>x.businessNo===x.dispatchNo),'OWN_DISPATCH_IDENTITY_REQUIRED') + return load +} +export function assertSameRouteCost(summary,departure,shares,amount='1.00') { + const load=assertSameRouteBasis(departure,shares) + demand(summary?.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&summary.allocationBlockReason==null&&id(summary.departureVersion)===id(departure.recordVersion)&&summary.manifestHash===load.manifestHash,'IMMUTABLE_DEPART_BASIS_REQUIRED') + demand(summary.totals?.length===1&&summary.totals[0].currency==='USD','SINGLE_ORIGINAL_CURRENCY_REQUIRED') + const total=summary.totals[0],expected=expectedAllocations(amount,shares),actual=[...(total.allocations||[])].sort((a,b)=>a.businessNo.localeCompare(b.businessNo)).map(x=>({businessNo:x.businessNo,sourceType:x.sourceType,dispatchNo:x.dispatchNo,palletSpaces:Number(x.palletSpaces),cents:String(cents(x.amount))})) + demand(cents(total.directCost)===cents(amount)&&same(actual,expected),'EXACT_LARGEST_REMAINDER_ALLOCATION_REQUIRED') + demand(cents(total.ownCost)===expected.filter(x=>x.sourceType==='OWN').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.externalCost)===expected.filter(x=>x.sourceType==='EXTERNAL').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.ownCost)+cents(total.externalCost)===cents(amount),'OWN_EXTERNAL_CENT_CONSERVATION_REQUIRED') + return expected +} + +export async function verifySameRouteCost(context,fixture,{execute=false,readInventory}={}) { + if(!execute)return {scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',businessSqlWrites:false} + demand(context?.marker&&context.marker===fixture?.marker&&fixture?.pdaToken&&fixture?.members?.length===2 + &&context.createdResources?.fleetTrips?.some(x=>x.tripNo===fixture.tripNo)&&typeof readInventory==='function','CURRENT_TWO_CUSTOMER_FIXTURE_REQUIRED') + demand(!context.createdResources.fleetSameRouteCost,'SAME_ROUTE_VERIFICATION_ALREADY_STARTED') + const proof=context.createdResources.fleetSameRouteCost={status:'CHECKING',tripNo:fixture.tripNo,marker:context.marker,scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',snapshots:{},requests:[]} + const check=(name,condition)=>{context.assert(MODULE,name,null,Boolean(condition),'',{scenario:MODULE,deploymentBlocker:true});demand(condition,name)} + const request=async(method,path,body,token=context.token)=>{const r=await context.request(method,path,body,token);proof.requests.push({method,path,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});check(`${method} ${path}`,r.http===200&&id(r.body?.code)==='200'&&r.requestId&&r.traceId);return r.body.data} + const trip=context.createdResources.fleetTrips.find(x=>x.tripNo===fixture.tripNo),base=`${ROOT}/fleet/trips/${fixture.tripNo}`,key=suffix=>`${context.marker}-SAME-${suffix}` + const inventory=async label=>{const rows=[];for(const customerId of [...new Set(fixture.members.map(x=>x.customerId))])rows.push(await readInventory({marker:context.marker,customerId,warehouseId:fixture.warehouseId}));proof.snapshots[label]=rows;return rows} + try{ + let detail=await request('GET',base),plan=await request('GET',`${base}/capacity-plan`) + check('Two owned sources have one real FBA unloading destination',plan.cargo?.length===2&&new Set(plan.cargo.map(x=>x.destinationKey)).size===1&&plan.cargo.every(x=>x.destinationKey?.startsWith('FBA:'))) + await request('POST',`${base}/capacity-plan`,{recordVersion:plan.recordVersion,manifestHash:plan.manifestHash,pallets:controlledPalletMeasurements(plan.cargo,detail.trip.stops),reason:context.marker}) + await verifyFleetExternalReceipt(context,MODULE,trip,fixture.pdaToken) + await verifyFleetExternalDispatch(context,MODULE,trip) + const receipt=trip.externalReceipt,external=trip.externalDispatch + const members=proof.members=[...fixture.members,{customerId:external.customerId,dispatchNo:external.dispatchNo,outboundNo:external.outboundNo,deliveryNo:external.deliveryNo,palletId:receipt.warehousePalletId,palletNo:receipt.palletNo,positionId:id(receipt.finalInventory.positions[0].id)}] + check('Only three distinct current-run dispatches and pallets',new Set(members.map(x=>x.dispatchNo)).size===3&&new Set(members.map(x=>x.palletId)).size===3) + for(const[index,m]of members.entries()){ + const task=await request('GET',`${PDA}/${m.dispatchNo}`,undefined,fixture.pdaToken),item=task.items?.[0] + check('PDA item is the original one-box pallet and source position',task.tripNo===fixture.tripNo&&task.items.length===1&&item.palletNo===m.palletNo&&Number(item.scheduledPackages)===1&&Number(item.pickedPackages)===0&&item.positions?.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===1)) + const pick={positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} + await request('POST',`${PDA}/${m.dispatchNo}/items/${item.dispatchItemId}/pick`,pick,fixture.pdaToken) + await request('POST',`${PDA}/${m.dispatchNo}/complete`,{idempotencyKey:key(`PICK-COMPLETE-${index}`)},fixture.pdaToken) + } + const stock=await inventory('PICKED'),positions=stock.flatMap(x=>x.positions) + check('All three original physical positions remain with zero available stock',positions.length===3&&members.every(m=>positions.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===0))) + const loading=await request('GET',`${base}/loading`);detail=await request('GET',base) + check('Three real pallets ready for loading',!loading.blockReason&&loading.cargo.length===3) + const measured=await request('POST',`${base}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:controlledPalletMeasurements(loading.cargo,detail.trip.stops),reason:context.marker}) + const approved=await request('POST',`${base}/loading-review`,{recordVersion:measured.recordVersion,decision:'APPROVED'}) + const depart={recordVersion:approved.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker},departPath=`${PDA}/${members[0].dispatchNo}/depart` + proof.departure=await request('POST',departPath,depart,fixture.pdaToken) + check('Shared actual departure contains all three customer executions',proof.departure.tripNo===fixture.tripNo&&proof.departure.status==='IN_TRANSIT'&&Number(proof.departure.shippedPackages)===3&&same(proof.departure.dispatches.map(x=>x.dispatchNo).sort(),members.map(x=>x.dispatchNo).sort())) + const departedTrip=proof.departedTrip=await request('GET',base),replay=await request('POST',departPath,depart,fixture.pdaToken) + check('Departure replay is explicit and does not change complete trip or stock',replay.replayed===true&&same(departedTrip,await request('GET',base))&&same(stock,await inventory('DEPARTED'))) + const history=await request('GET',`${base}/history?page=1&size=200`) + check('Complete trip history contains one actual departure',Number(history.total)===history.records.length&&history.records.filter(x=>x.action==='DEPART').length===1) + const departure=proof.departureHistory=history.records.find(x=>x.action==='DEPART') + const shares=proof.shares=members.map((m,i)=>({businessNo:i===2?receipt.bookingNo:m.dispatchNo,sourceType:i===2?'EXTERNAL':'OWN',dispatchNo:m.dispatchNo,customerId:m.customerId,palletSpaces:1,warehousePalletIds:[m.palletId]})) + assertSameRouteBasis(departure,shares) + const costPath=`${base}/costs`,before=await request('GET',`${costPath}?page=1&size=100`) + check('Current trip has no prior cost entries',Number(before.page.total)===0&&before.page.records.length===0&&before.summary.totals.length===0) + check('Empty cost query already uses exact original same-route departure',before.summary.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&before.summary.allocationBlockReason==null&&id(before.summary.departureVersion)===id(departure.recordVersion)&&before.summary.manifestHash===departure.snapshot.confirmedLoad.manifestHash) + proof.costRequest={idempotencyKey:key('COST-USD'),details:{category:'FUEL',currency:'USD',amount:'1.00',incurredAt:new Date().toISOString(),remark:`${context.marker} same-route synthetic cost`}} + const cost=proof.cost=await request('POST',costPath,proof.costRequest),result=proof.costResult=await request('GET',`${costPath}?page=1&size=100`) + check('Exactly one USD cost belongs to original trip',cost.tripNo===fixture.tripNo&&cost.costNo&&Number(cost.recordVersion)===0&&Number(result.page.total)===1&&result.page.records.length===1&&result.page.records[0].costNo===cost.costNo) + proof.expectedAllocations=assertSameRouteCost(result.summary,departure,shares) + check('Exact cost command replay returns same persisted cost and unchanged allocation',same(result.page.records[0],await request('POST',costPath,proof.costRequest))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) + const costHistory=proof.costHistory=await request('GET',`${costPath}/${cost.costNo}/history?page=1&size=100`) + check('Only one immutable cost creation history',Number(costHistory.total)===1&&costHistory.records.length===1&&costHistory.records[0].action==='CREATE'&&costHistory.records[0].snapshot?.cost?.costNo===cost.costNo) + assertSameRouteCost(costHistory.records[0].snapshot.summary,departure,shares) + check('Cost writes preserve exact departure history and customer stock',same(history,await request('GET',`${base}/history?page=1&size=200`))&&same(stock,await inventory('COST_REPLAY'))) + for(const[index,m]of members.entries()){ + const path=`${ROOT}/delivery-orders/${m.deliveryNo}`,scope=`?customerId=${m.customerId}`,delivery=await request('GET',path+scope) + await request('PUT',`${path}/status${scope}`,{status:'DELIVERING',expectedVersion:delivery.order.version,remark:context.marker}) + const docs=await request('GET',`${path}/documents${scope}`),upload=await context.requestMultipart('POST',`${path}/documents/RECEIPT/files${scope}`,{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${m.deliveryNo}`)}) + check('Actual receipt upload belongs to delivery',upload.http===200&&id(upload.body?.code)==='200'&&upload.body.data?.fileId) + const documents=(docs.categories||[]).filter(x=>x.category!=='RECEIPT').map(x=>({category:x.category,fileIds:(x.files||[]).map(f=>f.fileId)}));documents.push({category:'RECEIPT',fileIds:[id(upload.body.data.fileId)]}) + await request('PUT',`${path}/documents${scope}`,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents}) + check('This actual delivery completed with saved receipt',(await request('GET',path+scope)).order.status==='DELIVERED') + } + check('Signing preserves all customer stock and original cost allocation',same(stock,await inventory('SIGNED'))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) + proof.status='VERIFIED';return proof + }catch(error){proof.status='FAILED';proof.error=error.message;throw error} +} + +export async function runSameRouteCost(config,{env=process.env,...dependencies}={}) { + if(!config.execute)return {...config,scope:'TWO_FRESH_OWN_RECEIPTS_PLUS_ONE_EXTERNAL_SAME_ROUTE',cost:'USD 1.00, three independently proven pallet sources, largest remainder by business number',sqlWrites:false,deploy:false,cleanup:'Retain samples; disable own PDA and close own sessions'} + const files=['scripts/fleet-same-route-cost-test-env.mjs','scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs','scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs','scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs'],sources=files.map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) + return runCrossCustomer(config,{...dependencies,env,verify:async(context,fixture,options)=>{ + fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'same-route-source.json'),JSON.stringify({scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',sources},null,2),{flag:'wx'}) + return verifySameRouteCost(context,fixture,options) + }}) +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){try{const config=parseCrossCustomerArguments(process.argv.slice(2)),result=await runSameRouteCost(config);console.log(JSON.stringify(config.execute?{success:result.success,error:result.error,summary:result.summary,requestCount:result.requestCount,goalComplete:false}:result,null,2));if(config.execute&&!result.success)process.exitCode=1}catch(error){console.error(error.message);process.exitCode=1}} diff --git a/scripts/fleet-same-route-cost-test-env.test.mjs b/scripts/fleet-same-route-cost-test-env.test.mjs new file mode 100644 index 000000000..d459a8e07 --- /dev/null +++ b/scripts/fleet-same-route-cost-test-env.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {assertSameRouteCost,assertSameRouteBasis,runSameRouteCost,verifySameRouteCost} from './fleet-same-route-cost-test-env.mjs' +function sample(){ + const shares=[{businessNo:'DS-A',dispatchNo:'DS-A',sourceType:'OWN',customerId:'101',palletSpaces:1,warehousePalletIds:['1']},{businessNo:'DS-B',dispatchNo:'DS-B',sourceType:'OWN',customerId:'102',palletSpaces:1,warehousePalletIds:['2']},{businessNo:'BK-C',dispatchNo:'DS-C',sourceType:'EXTERNAL',customerId:'101',palletSpaces:1,warehousePalletIds:['3']}] + const departure={action:'DEPART',recordVersion:'7',snapshot:{trip:{stops:[{name:'Origin'},{destinationKey:'FBA:A'},{destinationKey:'FBA:B'}]},confirmedLoad:{manifestHash:'real-hash',pallets:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],palletSpaces:1,deliveryIndex:1})),cargo:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],dispatchNo:x.dispatchNo,customerId:x.customerId,packages:1,destinationKey:'FBA:A'}))},externalBookings:[{bookingNo:'BK-C',dispatchNo:'DS-C',customerId:'101',status:'BOUND',receipt:{source:{receivedPackages:1,pallets:[{warehousePalletId:'3',packages:1}]}}}]}} + // Three equal independent sources: the lexical first business number receives the residual cent. + const summary={departureVersion:'7',manifestHash:'real-hash',allocationMethod:'SAME_ROUTE_PALLET_SPACES',totals:[{currency:'USD',directCost:'1.00',ownCost:'0.66',externalCost:'0.34',allocations:shares.map(x=>({businessNo:x.businessNo,dispatchNo:x.dispatchNo,sourceType:x.sourceType,palletSpaces:1,amount:x.sourceType==='OWN'?'0.33':'0.34'}))}]} + return{shares,departure,summary} +} +test('offline entry does not call remote services and missing runtime scope is rejected',async()=>{ + assert.equal((await runSameRouteCost({execute:false},{env:{}})).deploy,false) + assert.equal((await verifySameRouteCost()).businessSqlWrites,false) + await assert.rejects(verifySameRouteCost({},null,{execute:true}),/FIXTURE/) +}) +test('exact three-source penny conservation follows real business-number tie break',()=>{ + const s=sample();assert.equal(assertSameRouteBasis(s.departure,s.shares).pallets.length,3) + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>[x.businessNo,x.cents]),[['BK-C','34'],['DS-A','33'],['DS-B','33']]) +}) +for(const fault of ['wrong action','missing pallet','overlapping source','different destination','wrong customer','wrong dispatch','wrong receipt','unbound booking','uncovered external','stale version','stale hash','invented method','mixed currency','lost penny','wrong source type','wrong aggregate','wrong residual winner'])test(`rejects invalid allocation evidence: ${fault}`,()=>{ + const s=sample(),l=s.departure.snapshot.confirmedLoad,t=s.summary.totals[0],b=s.departure.snapshot.externalBookings[0] + if(fault==='wrong action')s.departure.action='PLAN' + if(fault==='missing pallet')l.pallets.pop() + if(fault==='overlapping source')s.shares[1].warehousePalletIds=['1'] + if(fault==='different destination')l.pallets[2].deliveryIndex=2 + if(fault==='wrong customer')l.cargo[0].customerId='FOREIGN' + if(fault==='wrong dispatch')l.cargo[0].dispatchNo='DS-FOREIGN' + if(fault==='wrong receipt')b.receipt.source.pallets[0].packages=2 + if(fault==='unbound booking')b.status='CONFIRMED' + if(fault==='uncovered external')s.departure.snapshot.externalBookings.push({...b,bookingNo:'BK-OTHER'}) + if(fault==='stale version')s.summary.departureVersion='8' + if(fault==='stale hash')s.summary.manifestHash='new-hash' + if(fault==='invented method')s.summary.allocationMethod='GUESS' + if(fault==='mixed currency')t.currency='CNY' + if(fault==='lost penny')t.allocations[0].amount='0.34' + if(fault==='wrong source type')t.allocations[2].sourceType='OWN' + if(fault==='wrong aggregate'){t.ownCost='0.67';t.externalCost='0.33'} + if(fault==='wrong residual winner'){t.allocations[0].amount='0.34';t.allocations[2].amount='0.33';t.ownCost='0.67';t.externalCost='0.33'} + assert.throws(()=>assertSameRouteCost(s.summary,s.departure,s.shares)) +}) -- 2.54.0 From 055cd29e29be31cbbb9d5498cfa519e17eb4db0a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 14:40:40 +0800 Subject: [PATCH 136/176] fix(test): resolve loading customer credentials per run --- scripts/admin-test-env-modules/README.md | 2 ++ .../phase3-loading-credentials.test.mjs | 31 +++++++++++++++++++ .../admin-test-env-modules/phase3-loading.mjs | 16 +++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-loading-credentials.test.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 02eb06ead..8a8aed754 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -46,6 +46,8 @@ `phase3-loading.mjs` 的 PDA 原子证据上传在未取得 HTTP 响应时最多尝试三次,冻结原 URL、作业员身份、幂等键、文件名/内容、证据版本和封签号;不重试 HTTP/业务拒绝或已收到响应后的读取失败。`createdResources.loadingEvidenceTransport` 保存各次结果及白名单网络错误码,不保存凭据或原始错误对象。`phase3-evidence-transport-contract.test.mjs` 用持久幂等模型验证响应丢失重放仅一条证据、持续传输失败有界终止及拒绝不重试;离线模型不替代真实 HTTP。 +装柜模块每次执行时读取当前客户凭据,依次选本轮 `merchantQualificationFixture`、显式客户参数、显式加载的环境变量;选中的身份缺用户名或密码时在业务请求前停止,不能借另一客户补齐。不得在模块导入时缓存客户账号;`phase3-loading-credentials.test.mjs` 覆盖同进程切换客户、显式配置、当前环境读取及缺凭据写前拒绝。 + `orders.mjs` 每次运行优先使用当前 `merchantQualificationFixture` 的完整账号对,其次为明确配置;不在模块 import 时缓存客户凭据。当前 fixture 不完整时停止,不与上一轮或环境账号拼接。`orders-session-contract.test.mjs` 在同进程重复调用真实订单登录入口,覆盖连续客户隔离,防止多模式串行时沿用前一轮身份。 `phase4-overseas-pda.mjs` 的收货入口对本轮 `containerBookingId + containerNo` 做有界候选收敛等待(最多 30 次、间隔 1 秒),只有唯一且可操作的原柜才继续;鉴权失败、畸形响应和重复候选立即阻断。它用于等待异步船节点投影,不构成事件重放证据。F40 在成功和失败时均保留 `receivingCandidateEvidence` 的逐次查询身份、结果和诊断信息,不覆盖已有收货会话,不借其他柜或历史柜继续。 diff --git a/scripts/admin-test-env-modules/phase3-loading-credentials.test.mjs b/scripts/admin-test-env-modules/phase3-loading-credentials.test.mjs new file mode 100644 index 000000000..58d3a3063 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-credentials.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import * as loading from './phase3-loading.mjs' + +test('cached loading module resolves each fresh customer independently', () => { + const context={context:{merchantQualificationFixture:{username:'customer-a',password:'test-a'}},options:{merchantUsername:'configured',merchantPassword:'configured-test'}} + assert.deepEqual(loading.loadingMerchantCredentials(context),{username:'customer-a',password:'test-a'}) + context.context.merchantQualificationFixture={username:'customer-b',password:'test-b'} + assert.deepEqual(loading.loadingMerchantCredentials(context),{username:'customer-b',password:'test-b'}) +}) + +test('explicit credentials take precedence; environment is read at invocation', () => { + const env={MERCHANT_TEST_USERNAME:'env-a',MERCHANT_TEST_PASSWORD:'test-env-a'} + assert.deepEqual(loading.loadingMerchantCredentials({options:{merchantUsername:'configured',merchantPassword:'test-configured'}},env),{username:'configured',password:'test-configured'}) + assert.deepEqual(loading.loadingMerchantCredentials({},env),{username:'env-a',password:'test-env-a'}) + env.MERCHANT_TEST_USERNAME='env-b';env.MERCHANT_TEST_PASSWORD='test-env-b' + assert.deepEqual(loading.loadingMerchantCredentials({},env),{username:'env-b',password:'test-env-b'}) +}) + +test('incomplete selected identity cannot fall back to another customer', () => { + const env={MERCHANT_TEST_USERNAME:'unrelated',MERCHANT_TEST_PASSWORD:'unrelated-test'} + for(const context of [{context:{merchantQualificationFixture:{username:'current'}}},{options:{merchantPassword:'orphan-test'}}]) { + assert.throws(()=>loading.loadingMerchantCredentials(context,env),/完整客户登录凭据/) + } +}) + +test('loading stops before business requests when its current customer lacks credentials', async () => { + let requests=0 + await assert.rejects(loading.run({context:{merchantQualificationFixture:{username:'missing-password'}},options:{},createdResources:{},addStep(){},request:async()=>{requests++;throw Error('business request must not run')}}),/完整客户登录凭据/) + assert.equal(requests,0) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 720e549fe..86fc02d2f 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -7,12 +7,19 @@ import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } +export function loadingMerchantCredentials(context, environment = process.env) { + const fixture = context.context?.merchantQualificationFixture + const options = context.options || {} + const selected = fixture || (options.merchantUsername || options.merchantPassword + ? { username: options.merchantUsername, password: options.merchantPassword } + : { username: environment.MERCHANT_TEST_USERNAME, password: environment.MERCHANT_TEST_PASSWORD }) + if (!selected.username || !selected.password) throw new Error('装柜验收当前运行缺少完整客户登录凭据') + return { username: selected.username, password: selected.password } +} export function loadingBookingCustomerFields(scope = 'CUSTOMER', customerId, customerName) { if (scope === 'PLATFORM') return {} if (scope !== 'CUSTOMER') throw new Error('三期订舱范围必须明确为 CUSTOMER 或 PLATFORM') @@ -184,6 +191,7 @@ function taskItemsSignature(task) { } export async function run(context) { + const merchantCredentials = loadingMerchantCredentials(context) const source = fixture(context) const tenantId = id(source?.tenantId) const inboundId = id(source?.inboundId) @@ -940,12 +948,12 @@ export async function run(context) { } const merchantLogin = await context.request('POST', '/api/auth/login', { - loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: merchantCredentials.username, password: merchantCredentials.password, }, '') const merchantToken = merchantLogin.http === 200 && codeOf(merchantLogin) === '200' ? merchantLogin.body?.data?.accessToken : null context.addStep(MODULE, '取得客户侧隔离令牌用于 PDA 批次越权验证', merchantLogin, - Boolean(merchantToken), `username=${MERCHANT_USERNAME}`, OPTIONS) + Boolean(merchantToken), 'current-run customer credentials', OPTIONS) const customerIsolatedBatch = merchantToken ? await request(context, 'POST', batchPath, batchPayload, { Authorization: `Bearer ${merchantToken}`, -- 2.54.0 From cbd5dc0ca2746d38bce25f1529a6ab073889d078 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 15:00:24 +0800 Subject: [PATCH 137/176] perf(test): normalize report secrets once per redaction --- scripts/fba-core-regression-test-env.mjs | 17 +++++++++++------ scripts/fba-core-regression-test-env.test.mjs | 11 +++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/scripts/fba-core-regression-test-env.mjs b/scripts/fba-core-regression-test-env.mjs index 1f6229a6f..621416ae0 100644 --- a/scripts/fba-core-regression-test-env.mjs +++ b/scripts/fba-core-regression-test-env.mjs @@ -68,12 +68,17 @@ export function validateCoreEnvironment(env,root=repositoryRoot) { return base.origin } export function redactEvidence(value,secrets=[]) { - if(Array.isArray(value))return value.map(item=>redactEvidence(item,secrets)) - if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key, - /^(?:.*password|.*token|authorization|cookie|gateway|jenkinsUrl|privateKey)$/i.test(key)?'[REDACTED]':redactEvidence(item,secrets)])) - if(typeof value==='string')return secrets.filter(secret=>typeof secret==='string'&&secret.length>0) - .reduce((text,secret)=>text.split(secret).join('[REDACTED]'),value) - return value + // Long runs can register the same credential repeatedly. Normalize once per + // report, rather than filtering and reapplying it for every nested string. + const uniqueSecrets=[...new Set(secrets)].filter(secret=>typeof secret==='string'&&secret.length>0) + function redact(item) { + if(Array.isArray(item))return item.map(redact) + if(item&&typeof item==='object')return Object.fromEntries(Object.entries(item).map(([key,child])=>[key, + /^(?:.*password|.*token|authorization|cookie|gateway|jenkinsUrl|privateKey)$/i.test(key)?'[REDACTED]':redact(child)])) + if(typeof item==='string')return uniqueSecrets.reduce((text,secret)=>text.split(secret).join('[REDACTED]'),item) + return item + } + return redact(value) } export function modePassed(report) { const proof=report?.createdResources?.phase5RootFulfillment diff --git a/scripts/fba-core-regression-test-env.test.mjs b/scripts/fba-core-regression-test-env.test.mjs index ace7bca1d..3e8d11926 100644 --- a/scripts/fba-core-regression-test-env.test.mjs +++ b/scripts/fba-core-regression-test-env.test.mjs @@ -124,6 +124,17 @@ test('preflight failure creates summary but no HTTP fixture; existing run or act await assert.rejects(runFbaCore({...config(),runId:'next',outputDir:'reports/next'},{env:env(),outputRoot,readers:readers()})) assert.equal(readFileSync(lock,'utf8'),'occupied') }) +test('redaction scans the secret input once for the complete report tree',()=>{ + let reads=0 + const values=Array.from({length:80},()=> 'synthetic-private-value') + const secrets=new Proxy(values,{get(target,key,receiver){if(/^\d+$/.test(String(key)))reads++;return Reflect.get(target,key,receiver)}}) + const report={steps:Array.from({length:40},()=>({message:'prefix synthetic-private-value suffix',nested:{token:'hidden',public:'kept'}}))} + const result=redactEvidence(report,secrets) + assert.ok(result.steps.every(row=>row.message==='prefix [REDACTED] suffix'&&row.nested.token==='[REDACTED]'&&row.nested.public==='kept')) + assert.equal(reads,values.length,'normalization must not repeat for every evidence string') + assert.equal(report.steps[0].message,'prefix synthetic-private-value suffix','input report is not modified') +}) + test('reports redact dynamically created account secrets and environment/token values',()=>{ const value=redactEvidence({password:'x',pdaToken:'y',message:'failure private-secret',nested:{gateway:'url',value:'safe'}},['private-secret']) assert.equal(value.password,'[REDACTED]');assert.equal(value.pdaToken,'[REDACTED]') -- 2.54.0 From 22133fe6041d3509b90fe60f2e225598026f4c5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 18:04:21 +0800 Subject: [PATCH 138/176] fix(firstleg): project completed loading lists from actual picked cargo --- .../mapper/LoadingListQueryMapper.java | 53 ++++- .../LoadingListActualCargoTest.java | 183 ++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 1ebb4d447..22a4f2fbc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -19,6 +19,35 @@ import java.util.List; /** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */ @Mapper public interface LoadingListQueryMapper { + // Completed projections use the same settled physical scope as LoadingCompletedEventMapper. + // Keep skipped plan rows for history and for the unfinished preload projection. + String ITEM_SCOPE = """ + AND (p.plan_status != 'COMPLETED' OR EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=i.package_count)) + """; + String PALLET_ITEM_SCOPE = """ + AND (p.plan_status != 'COMPLETED' OR EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=pallet_item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=pallet_item.package_count)) + """; + String CUSTOMER_ACTUAL_SCOPE = """ + AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=customer_item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=customer_item.package_count) + """; String AGGREGATE_COLUMNS = """ p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.created_by, p.created_by_name, p.created_at, p.version, @@ -50,28 +79,36 @@ public interface LoadingListQueryMapper { FROM fl_dropship_pallet_carton carton WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_package_count, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_package_count, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.gross_weight_kg ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_gross_weight_kg/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_gross_weight_kg, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_gross_weight_kg, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.net_weight_kg ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_net_weight_kg/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_net_weight_kg, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_net_weight_kg, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.volume_cbm ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_volume_cbm/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_volume_cbm, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_volume_cbm, COALESCE(SUM(i.product_quantity), 0) AS total_quantity """; @@ -115,8 +152,11 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.plan_status = 'COMPLETED' - AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId} + """ + CUSTOMER_ACTUAL_SCOPE + """ + ) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') @@ -138,6 +178,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.id = #{planId} GROUP BY """ + AGGREGATE_GROUP) @@ -149,6 +190,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.container_booking_id = #{bookingId} GROUP BY """ + AGGREGATE_GROUP) @@ -224,6 +266,7 @@ public interface LoadingListQueryMapper { JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' LEFT JOIN fl_inbound_order inbound ON inbound.id=i.inbound_id AND inbound.tenant_id=i.account_id WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 + """ + ITEM_SCOPE + """ ORDER BY i.load_sequence, i.id """) List selectItems(@Param("planId") long planId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java new file mode 100644 index 000000000..f58575bb2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java @@ -0,0 +1,183 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Executes the real SQL, including pagination COUNT and correlated physical-pallet totals. */ +class LoadingListActualCargoTest { + private JdbcTemplate jdbc; + private LoadingListQueryMapper mapper; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:loaded-list-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + table("fl_loading_plan", LoadingPlanEntity.class); + table("fl_loading_plan_item", LoadingPlanItemEntity.class); + table("fl_container_booking", ContainerBookingEntity.class); + table("fl_inbound_order", InboundOrderEntity.class); + jdbc.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT, confirmed_at TIMESTAMP, reversed_at TIMESTAMP, status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_preload_loading_plan_link(loading_plan_id BIGINT, preload_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_preload_plan(id BIGINT, status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_ocean_booking_container_allocation(container_booking_id BIGINT, ocean_booking_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT, status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT, loading_plan_id BIGINT, task_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT, pick_task_id BIGINT, item_status VARCHAR, picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT, pallet_id BIGINT, inbound_id BIGINT, tenant_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_inbound_weight_result(tenant_id BIGINT, inbound_id BIGINT, deleted INT, recognition_status VARCHAR)"); + jdbc.update("INSERT INTO fl_container_booking(id,warehouse_id,record_status) VALUES(10,1,'ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_plan(id,container_booking_id,plan_status,created_at) VALUES(1,10,'COMPLETED',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1,'COMPLETED'),(200,2,'COMPLETED')"); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(LoadingListQueryMapper.class); + var pagination = new MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(configuration); + factory.setPlugins(pagination); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingListQueryMapper.class); + } + + @Test + void completedDetailAndListExcludeSkippedAndUnsettledRowsFromAllMeasures() { + ordinaryCargo(); + assertActualTotals(mapper.selectAggregate(1)); + assertActualTotals(mapper.selectAggregateByBooking(10)); + assertThat(mapper.selectItems(1)).extracting(LoadingListQueryMapper.ItemRow::getItemId) + .containsExactly(1L, 2L); + var page = mapper.selectConfirmedPage(new Page<>(1, 1), query(null)); + assertThat(page.getTotal()).isEqualTo(1); + assertThat(page.getRecords()).singleElement().satisfies(this::assertActualTotals); + } + + @Test + void customerWithOnlySkippedCargoCannotMatchCompletedListCountOrRows() { + ordinaryCargo(); + var skipped = mapper.selectConfirmedPage(new Page<>(1, 1), query(22L)); + assertThat(skipped.getTotal()).isZero(); + assertThat(skipped.getRecords()).isEmpty(); + var loaded = mapper.selectConfirmedPage(new Page<>(1, 1), query(11L)); + assertThat(loaded.getTotal()).isEqualTo(1); + assertThat(loaded.getRecords()).singleElement().satisfies(this::assertActualTotals); + } + + @Test + void emptyActualScopeDoesNotHideThePlanIdentityOrFallBackToPlannedTotals() { + item(1, 22, 1, null, "SKIPPED", 0, 100); + var row = mapper.selectAggregate(1); + assertThat(row.getPlanId()).isEqualTo(1); + assertThat(row.getPlanStatus()).isEqualTo("COMPLETED"); + assertThat(row.getTotalPackageCount()).isZero(); + assertThat(row.getTotalGrossWeightKg()).isEqualByComparingTo("0"); + assertThat(mapper.selectItems(1)).isEmpty(); + } + + @Test + void unfinishedPreloadKeepsPlannedCargoIncludingPendingAndSkippedItems() { + ordinaryCargo(); + jdbc.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=1"); + assertThat(mapper.selectItems(1)).hasSize(6); + assertThat(mapper.selectAggregate(1).getTotalPackageCount()).isEqualTo(8); + var page = mapper.selectPreloadPage(new Page<>(1, 1), + new PreloadQuery(null, null, 22L, null, null, null, 1, 1)); + assertThat(page.getTotal()).isEqualTo(1); + assertThat(page.getRecords()).singleElement().satisfies(row -> + assertThat(row.getTotalPackageCount()).isEqualTo(8)); + } + + @Test + void physicalPalletTotalsIgnoreUnloadedPalletsAndDoNotCountSkuFragmentsTwice() { + item(1, 11, 2, 501L, "PICKED", 2, 100); + item(2, 11, 2, 501L, "PICKED", 2, 100); + item(3, 22, 1, 502L, "SKIPPED", 0, 100); + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,total_package_count,actual_gross_weight_kg,actual_net_weight_kg,actual_volume_cbm) VALUES(700,11,2,20,16,4),(701,22,1,999,999,999)"); + jdbc.update("INSERT INTO fl_dropship_pallet_carton VALUES(1,501,700,11),(2,501,700,11),(3,502,701,22)"); + var actual = mapper.selectAggregate(1); + assertThat(actual.getTotalPackageCount()).isEqualTo(2); + assertThat(actual.getTotalGrossWeightKg()).isEqualByComparingTo("20"); + assertThat(actual.getTotalNetWeightKg()).isEqualByComparingTo("16"); + assertThat(actual.getTotalVolumeCbm()).isEqualByComparingTo("4"); + assertThat(actual.getTotalQuantity()).isEqualTo(40); + assertThat(mapper.selectItems(1)).hasSize(2); + assertThat(mapper.selectConfirmedPage(new Page<>(1, 1), query(22L)).getTotal()).isZero(); + jdbc.update("UPDATE fl_loading_plan SET plan_status='EDITING' WHERE id=1"); + assertThat(mapper.selectAggregate(1).getTotalPackageCount()).isEqualTo(3); + assertThat(mapper.selectAggregate(1).getTotalGrossWeightKg()).isEqualByComparingTo("1019"); + assertThat(mapper.selectItems(1)).hasSize(3); + } + + private void ordinaryCargo() { + item(1, 11, 2, null, "PICKED", 2, 100); + item(2, 11, 1, null, "PARTIAL", 1, 100); + item(3, 22, 1, null, "SKIPPED", 0, 100); + item(4, 22, 1, null, "PENDING", 0, 100); + item(5, 22, 1, null, "PICKED", 1, 200); // wrong task belongs to a different plan + item(6, 22, 2, null, "PARTIAL", 1, 100); // not reconciled to the final fragment + item(7, 11, 9, null, "PICKED", 9, 100); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=7"); + } + + private void item(long id, long customer, int packages, Long pallet, String status, int picked, long task) { + jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,sku_code,package_count,product_quantity,gross_weight_kg,net_weight_kg,volume_cbm,pallet_id,pallet_count,deleted,load_sequence) VALUES(?,1,?,?,?,?,?,?,?,?,1,0,?)", + id, customer, "SKU-" + id, packages, packages * 10, packages * 5, packages * 4, packages, pallet, id); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,?)", id, task, status, picked); + } + + private ConfirmedQuery query(Long customer) { + return new ConfirmedQuery(null, customer, null, null, null, 1, 1); + } + + private void assertActualTotals(LoadingListQueryMapper.AggregateRow row) { + assertThat(row.getTotalPackageCount()).isEqualTo(3); + assertThat(row.getTotalQuantity()).isEqualTo(30); + assertThat(row.getSkuCount()).isEqualTo(2); + assertThat(row.getTotalGrossWeightKg()).isEqualByComparingTo("15"); + assertThat(row.getTotalNetWeightKg()).isEqualByComparingTo("12"); + assertThat(row.getTotalVolumeCbm()).isEqualByComparingTo("3"); + } + + private void table(String name, Class entity) { + var columns = new ArrayList(); + for (var type = entity; type != Object.class; type = type.getSuperclass()) { + for (var field : type.getDeclaredFields()) { + var annotation = field.getAnnotation(TableField.class); + if (Modifier.isStatic(field.getModifiers()) || annotation != null && !annotation.exist()) continue; + String column = field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + Class value = field.getType(); + String sqlType = value == Long.class || value == long.class ? "BIGINT" + : value == Integer.class || value == int.class || value == Boolean.class || value == boolean.class ? "INT" + : value == BigDecimal.class ? "DECIMAL(20,6)" + : value == java.time.LocalDateTime.class ? "TIMESTAMP" : "VARCHAR(2000)"; + columns.add(column + " " + sqlType); + } + } + jdbc.execute("CREATE TABLE " + name + " (" + String.join(",", columns) + ")"); + } +} -- 2.54.0 From 7319fba35afa38383d09115f054a9d71ef81b6f8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 19:01:40 +0800 Subject: [PATCH 139/176] fix(firstleg): settle and validate actual partial loading cargo --- deploy/sql/README.md | 4 +- .../V7__settle_partial_loaded_cargo.sql | 124 +++++++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 28 +- .../mapper/ContainerCustomsMapper.java | 29 ++ .../mapper/InboundSplitSettlementMapper.java | 19 +- .../mapper/LoadingCompletedEventMapper.java | 31 ++- .../mapper/LoadingPalletLabelMapper.java | 42 ++- .../LoadingCompletedOutboxService.java | 53 +++- .../service/LoadingPalletLabelService.java | 33 ++- .../db/phase3-smart-loading-schema.sql | 28 +- .../ContainerCustomsActualCargoTest.java | 135 ++++++++++ .../PartialLoadingMetricsPersistenceTest.java | 103 ++++++++ .../PdaSplitSettlementSqlContractTest.java | 34 ++- ...LoadingCompletedPreflightDatabaseTest.java | 249 ++++++++++++++++++ .../LoadingPalletLabelServiceTest.java | 23 ++ .../partial-loading-actual-acceptance-plan.md | 50 ++++ 16 files changed, 936 insertions(+), 49 deletions(-) create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java create mode 100644 scripts/partial-loading-actual-acceptance-plan.md diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b5ddec95d..69ba2021a 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -72,12 +72,12 @@ IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授 | 区域 / schema | 迁移 | 目的 | |---|---|---| | CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 | -| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql` | 收敛一件代发托盘码非空定义;补齐墨家测量和人工报价字段、报关行回执/申报事实、船期日历事实表;V2 保留未上报重量为 NULL,放宽已有非空重量列,不回填或删除收件记录 | +| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql`、`V5__event_delivery_retry_audit.sql`、`V6__event_delivery_attempt.sql`、`V7__settle_partial_loaded_cargo.sql` | 收敛一件代发托盘码非空定义;补齐测量/报价、报关、船期和事件重试审计;V7 仅更新装柜明细保护触发器,允许普通箱件按冻结分配和实际拣货精确收敛件数、重量、体积和托数,保留身份及已完成冻结保护,不回填原事件或库存 | | CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | | OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 | | OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 | -上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=4`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 +上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}//`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql new file mode 100644 index 000000000..8ceefa595 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql @@ -0,0 +1,124 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: settle_partial_loaded_cargo +-- Preconditions: existing loading item/pick tables and trg_loading_item_before_update. +-- Postconditions: exact PACKAGE partial convergence; all other frozen fields remain protected. +-- No business-row backfill or replay of existing loading events. +DROP TRIGGER IF EXISTS trg_loading_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_update +BEFORE UPDATE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + IF current_plan_status = 'LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN + IF NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) + OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN + IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段'; + END IF; + ELSEIF NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_pick.loading_plan_item_id = OLD.id + AND actual_task.loading_plan_id = OLD.loading_plan_id + AND actual_pick.item_status = 'PARTIAL' + AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count < actual_pick.planned_package_count + AND OLD.package_count = actual_pick.planned_package_count + AND NEW.deleted = 0 + AND NEW.package_count = actual_pick.picked_package_count + ) OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0))) + OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载'; + END IF; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ +DELIMITER ; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 84a641170..9149abd55 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5771,11 +5771,17 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段'; END IF; ELSEIF NOT EXISTS ( - SELECT 1 FROM fl_loading_pick_item - WHERE loading_plan_item_id = OLD.id - AND item_status = 'PARTIAL' + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_pick.loading_plan_item_id = OLD.id + AND actual_task.loading_plan_id = OLD.loading_plan_id + AND actual_pick.item_status = 'PARTIAL' + AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count < actual_pick.planned_package_count + AND OLD.package_count = actual_pick.planned_package_count AND NEW.deleted = 0 - AND NEW.package_count = picked_package_count + AND NEW.package_count = actual_pick.picked_package_count ) OR NEW.deleted <> OLD.deleted OR NOT (NEW.customer_name <=> OLD.customer_name) OR NOT (NEW.inbound_id <=> OLD.inbound_id) @@ -5791,13 +5797,13 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i OR NOT (NEW.product_name_en <=> OLD.product_name_en) OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) OR NOT (NEW.product_image_url <=> OLD.product_image_url) - OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END) OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) - OR NOT (NEW.pallet_count <=> OLD.pallet_count) - OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) - OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) - OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) - OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0))) + OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)) OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) OR NOT (NEW.shipment_no <=> OLD.shipment_no) OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) @@ -5809,7 +5815,7 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i OR NOT (NEW.removed_by <=> OLD.removed_by) OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) OR NOT (NEW.removed_at <=> OLD.removed_at) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载'; END IF; END IF; IF OLD.frozen_at IS NOT NULL THEN diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index e4946da34..96b2c564d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -8,6 +8,16 @@ import java.util.List; @Mapper public interface ContainerCustomsMapper { + /** Same settled loading-item scope as the V3 handover; retained skipped rows are not customs cargo. */ + String ACTUAL_ITEM_SCOPE = """ + EXISTS (SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=item.loading_plan_id + AND actual_pick.loading_plan_item_id=item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=item.package_count) + """; String DOCUMENT_COLUMNS = """ d.id id,d.customs_container_id customsContainerId,d.tenant_id tenantId,d.file_id fileId, d.file_name fileName,d.file_hash fileHash,d.content_type contentType,d.size_bytes sizeBytes, @@ -36,6 +46,9 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} + AND (p.plan_status != 'COMPLETED' OR + """ + ACTUAL_ITEM_SCOPE + """ + ) ORDER BY i.tenant_id,i.id """) List planInbounds(@Param("planId") long planId); @@ -46,6 +59,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 + AND + """ + ACTUAL_ITEM_SCOPE + """ AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId}) AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode}) """) @@ -66,6 +81,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ ORDER BY COALESCE(p.confirmed_at,p.updated_at) DESC,p.id DESC LIMIT 1 """) Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); @@ -325,6 +342,8 @@ public interface ContainerCustomsMapper { SELECT DISTINCT i.id FROM fl_loading_plan_item item JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ ) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE' WHERE d.id IS NULL @@ -336,6 +355,8 @@ public interface ContainerCustomsMapper { FROM fl_loading_plan_item item JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ """) int taxRefundCount(@Param("planId") long planId); @@ -541,6 +562,8 @@ public interface ContainerCustomsMapper { LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0 + AND + """ + ACTUAL_ITEM_SCOPE + """ GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END) ) ORDER BY c.completed_at,c.id LIMIT #{limit} """) @@ -557,6 +580,8 @@ public interface ContainerCustomsMapper { JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND + """ + ACTUAL_ITEM_SCOPE + """ AND item.id>#{cursor} AND item.id<=#{maximumId} ORDER BY item.id LIMIT #{limit} """) @@ -575,6 +600,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND + """ + ACTUAL_ITEM_SCOPE + """ """) long maximumBuyItemId(@Param("planId") long planId); @@ -583,6 +610,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId} + AND + """ + ACTUAL_ITEM_SCOPE + """ """) long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 683c17012..807698ed8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -250,14 +250,29 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_loading_plan_item plan_item JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id - SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0) - * pick_item.picked_package_count / NULLIF(plan_item.package_count,0)), + SET plan_item.pallet_count = CEIL(CAST(COALESCE(plan_item.pallet_count,0) AS DECIMAL(30,6)) + * pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)), + plan_item.product_quantity = CASE WHEN plan_item.units_per_carton IS NOT NULL + THEN plan_item.units_per_carton * pick_item.picked_package_count + ELSE ROUND(CAST(plan_item.product_quantity AS DECIMAL(30,6)) * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),0) END, + plan_item.gross_weight_kg = ROUND(plan_item.gross_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.net_weight_kg = ROUND(plan_item.net_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.dimensional_weight_kg = ROUND(plan_item.dimensional_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.volume_cbm = ROUND(plan_item.volume_cbm * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),4), plan_item.package_count = pick_item.picked_package_count, plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName}, plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1 WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0 + AND plan_item.unit_type = 'PACKAGE' AND plan_item.pallet_id IS NULL AND pick_item.item_status = 'PARTIAL' AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count < pick_item.planned_package_count + AND plan_item.package_count = pick_item.planned_package_count """) int convergePartialPlanItem(@Param("planItemId") long planItemId, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 073fad613..7cc76633b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -13,7 +13,7 @@ import java.util.List; /** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */ @Mapper public interface LoadingCompletedEventMapper { - @Select(""" + String SNAPSHOT_ROWS = """ SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, i.customer_name, @@ -29,7 +29,10 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark - ,i.customer_remark, i.internal_remark, i.warehouse_remark + ,i.customer_remark, i.internal_remark, i.warehouse_remark, + i.units_per_carton, i.unit_type, i.pallet_id, + actual_pick.planned_package_count, actual_pick.picked_package_count, + actual_pick.item_status AS pick_status FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 @@ -37,17 +40,33 @@ public interface LoadingCompletedEventMapper { ON actual_pick.loading_plan_item_id = i.id AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count > 0 - AND actual_pick.picked_package_count = i.package_count JOIN fl_loading_pick_task actual_task ON actual_task.id = actual_pick.pick_task_id AND actual_task.loading_plan_id = p.id JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND i.package_count > 0 - ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id - """) + """; + String SNAPSHOT_ORDER = " ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id"; + + @Select(SNAPSHOT_ROWS + " AND actual_pick.picked_package_count = i.package_count" + SNAPSHOT_ORDER) List rows(@Param("planId") long planId); + /** 尚未结算的 PACKAGE/PARTIAL 也必须接受整柜预检;这里只读原始冻结行。 */ + @Select(SNAPSHOT_ROWS + SNAPSHOT_ORDER) + List previewRows(@Param("planId") long planId); + + @Select(""" + SELECT COUNT(*) FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick ON actual_pick.loading_plan_item_id=i.id + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + AND actual_task.loading_plan_id=i.loading_plan_id + WHERE i.loading_plan_id=#{planId} AND i.deleted=0 + AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count>0 + AND actual_pick.picked_package_count<>i.package_count + """) + int unsettledActualItems(@Param("planId") long planId); + /** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */ @Select(""" SELECT i.account_id, i.inbound_id AS source_inbound_id, @@ -139,6 +158,8 @@ public interface LoadingCompletedEventMapper { private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private String businessType; private String fulfillmentType; private String lxCode; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; + private Integer unitsPerCarton; private String unitType; private Long palletId; + private Integer plannedPackageCount; private Integer pickedPackageCount; private String pickStatus; } @Getter @Setter diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java index faca5e4c3..883cd3b80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -18,7 +18,7 @@ public interface LoadingPalletLabelMapper { i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, i.destination_warehouse_code,i.destination_warehouse_name, COALESCE(NULLIF(i.pallet_count,0), - CEIL(COALESCE(NULLIF(o.pallet_count,0), + CEIL(CAST(COALESCE(NULLIF(o.pallet_count,0), NULLIF((SELECT COUNT(DISTINCT weight.id) FROM fl_inbound_pallet_allocation allocation JOIN fl_inbound_weight_result weight @@ -31,7 +31,7 @@ public interface LoadingPalletLabelMapper { AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), (SELECT COUNT(*) FROM fl_inbound_weight_result weight WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id - AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6)) * i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count, COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type, o.destination_type,i.load_sequence @@ -45,6 +45,44 @@ public interface LoadingPalletLabelMapper { """) List lockSources(@Param("planId") long planId); + /** 预测结算后的来源范围和托数;不锁行、不生成或占用标签。 */ + @Select(""" + SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id, + i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, + i.destination_warehouse_code,i.destination_warehouse_name, + COALESCE(NULLIF(CASE WHEN pick.picked_package_count0 AND allocation.slot_no>0 AND allocation.reservation_id>0 + AND allocation.allocated_package_count>0 + AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), + (SELECT COUNT(*) FROM fl_inbound_weight_result weight + WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6)) + * pick.picked_package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count, + COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type, + o.destination_type,i.load_sequence + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0 + JOIN fl_loading_pick_item pick ON pick.loading_plan_item_id=i.id + AND pick.item_status IN ('PICKED','PARTIAL') AND pick.picked_package_count>0 + JOIN fl_loading_pick_task task ON task.id=pick.pick_task_id AND task.loading_plan_id=p.id + LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0 + WHERE p.id=#{planId} + ORDER BY i.destination_warehouse_code,i.load_sequence,i.id + """) + List previewSources(@Param("planId") long planId); + @Select(""" SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id, account_id,inbound_id,inbound_code,container_no,destination_warehouse_code, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 93b11a592..63f00e5cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -28,6 +28,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; @@ -53,24 +54,33 @@ public class LoadingCompletedOutboxService { @Resource private ObjectMapper objectMapper; public void publish(long planId) { - preparedEnvelopes(planId).forEach(this::insert); + if (snapshotMapper.unsettledActualItems(planId) != 0) { + throw new BizException(ResultCode.CONFLICT, "实装明细尚未完成结算,不能发布尾程交接事件"); + } + preparedEnvelopes(planId, false).forEach(this::insert); } /** 在任何入库结算前验证跨区交接快照,避免整托/箱唛冲突留下半完成状态。 */ public void requirePublishable(long planId) { - preparedEnvelopes(planId); + preparedEnvelopes(planId, true); } - private List preparedEnvelopes(long planId) { - List rows = snapshotMapper.rows(planId); + private List preparedEnvelopes(long planId, boolean preview) { + List rows = preview ? snapshotMapper.previewRows(planId) : snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + if (preview) rows.forEach(LoadingCompletedOutboxService::projectSettledRow); validateContainerIdentity(rows); validateCustomerSnapshots(rows); - palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); + List standardLabels; + if (preview) { + standardLabels = palletLabelService.preview(planId, rows.get(0).getWarehouseId()); + } else { + palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); + standardLabels = palletLabelMapper.labels(planId); + } Map> cartonMarks = cartonLabelPools(rows); List palletRows = palletRows(planId); Map palletManifests = palletManifests(palletRows); - List standardLabels = palletLabelMapper.labels(planId); return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(), @@ -80,6 +90,37 @@ public class LoadingCompletedOutboxService { .toList(); } + /** 与 convergePartialPlanItem 相同的冻结分配口径;已结算的行绝不再次缩放。 */ + static void projectSettledRow(Row row) { + Integer actual = row.getPickedPackageCount(); + Integer planned = row.getPlannedPackageCount(); + Integer frozen = row.getPackageCount(); + if (actual == null || actual <= 0 || planned == null || planned < actual) { + throw new BizException(ResultCode.CONFLICT, "装柜实拣数量与计划快照不一致"); + } + if (actual.equals(frozen)) return; + if (!"PARTIAL".equals(row.getPickStatus()) || !"PACKAGE".equals(row.getUnitType()) + || row.getPalletId() != null || !planned.equals(frozen) || actual >= planned) { + throw new BizException(ResultCode.CONFLICT, "部分装柜尚未形成合法的冻结分配快照"); + } + Integer units = row.getUnitsPerCarton(); + row.setProductQuantity(units == null + ? (row.getProductQuantity() == null ? null : scaled(BigDecimal.valueOf(row.getProductQuantity()), actual, planned, 0).intValueExact()) + : Math.multiplyExact(units, actual)); + row.setPalletCount(BigDecimal.valueOf(Objects.requireNonNullElse(row.getPalletCount(), 0)) + .multiply(BigDecimal.valueOf(actual)).divide(BigDecimal.valueOf(planned), 0, RoundingMode.CEILING).intValueExact()); + row.setGrossWeightKg(scaled(row.getGrossWeightKg(), actual, planned, 3)); + row.setNetWeightKg(scaled(row.getNetWeightKg(), actual, planned, 3)); + row.setDimensionalWeightKg(scaled(row.getDimensionalWeightKg(), actual, planned, 3)); + row.setVolumeCbm(scaled(row.getVolumeCbm(), actual, planned, 4)); + row.setPackageCount(actual); + } + + private static BigDecimal scaled(BigDecimal value, int actual, int planned, int scale) { + return value == null ? null : value.multiply(BigDecimal.valueOf(actual)) + .divide(BigDecimal.valueOf(planned), scale, RoundingMode.HALF_UP); + } + /** 已开展逐箱打托的入仓单使用现场事实;未开展的存量入仓单继续兼容原托盘分配快照。 */ private List palletRows(long planId) { int workPalletCount = snapshotMapper.workPalletCount(planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java index 84ca04530..07295e586 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java @@ -47,6 +47,28 @@ public class LoadingPalletLabelService { @Transactional public PalletLabelManifest prepare(long planId, long warehouseId) { List sources = mapper.lockSources(planId); + List expected = expectedSources(sources, warehouseId); + List existing = mapper.labels(planId); + if (existing.isEmpty()) { + expected.forEach(row -> { + row.setLabelId(IdWorker.getId()); + if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); + }); + existing = mapper.labels(planId); + } + requireSame(expected, existing); + return manifest(sources.get(0), existing); + } + + /** 结算前验证预测标签,仅读取;不分配持久标识、锁行或新增标签。 */ + public List preview(long planId, long warehouseId) { + List expected = expectedSources(mapper.previewSources(planId), warehouseId); + List existing = mapper.labels(planId); + if (!existing.isEmpty()) requireSame(expected, existing); + return existing.isEmpty() ? expected : existing; + } + + private List expectedSources(List sources, long warehouseId) { if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细"); SourceRow first = sources.get(0); if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库"); @@ -64,14 +86,7 @@ public class LoadingPalletLabelService { throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码"); } Map internalCodes = resolveInternalCodes(standard); - List existing = mapper.labels(planId); - List expected = expected(first, standard, containerNo, internalCodes); - if (existing.isEmpty()) { - expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); }); - existing = mapper.labels(planId); - } - requireSame(expected, existing); - return manifest(first, existing); + return expected(first, standard, containerNo, internalCodes); } @Transactional @@ -103,7 +118,7 @@ public class LoadingPalletLabelService { for (int local = 0; local < source.getPalletCount(); local++) { int index = indexes.merge(source.getDestinationWarehouseId(), 1, Integer::sum); if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999"); - LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId()); + LabelRow row = new LabelRow(); row.setPlanId(plan.getPlanId()); row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId()); row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo); row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName()); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 8c289977c..cbb43637f 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -1094,11 +1094,17 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段'; END IF; ELSEIF NOT EXISTS ( - SELECT 1 FROM fl_loading_pick_item - WHERE loading_plan_item_id = OLD.id - AND item_status = 'PARTIAL' + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_pick.loading_plan_item_id = OLD.id + AND actual_task.loading_plan_id = OLD.loading_plan_id + AND actual_pick.item_status = 'PARTIAL' + AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count < actual_pick.planned_package_count + AND OLD.package_count = actual_pick.planned_package_count AND NEW.deleted = 0 - AND NEW.package_count = picked_package_count + AND NEW.package_count = actual_pick.picked_package_count ) OR NEW.deleted <> OLD.deleted OR NOT (NEW.customer_name <=> OLD.customer_name) OR NOT (NEW.inbound_id <=> OLD.inbound_id) @@ -1114,13 +1120,13 @@ BEGIN OR NOT (NEW.product_name_en <=> OLD.product_name_en) OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) OR NOT (NEW.product_image_url <=> OLD.product_image_url) - OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END) OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) - OR NOT (NEW.pallet_count <=> OLD.pallet_count) - OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) - OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) - OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) - OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0))) + OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)) OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) OR NOT (NEW.shipment_no <=> OLD.shipment_no) OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) @@ -1132,7 +1138,7 @@ BEGIN OR NOT (NEW.removed_by <=> OLD.removed_by) OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) OR NOT (NEW.removed_at <=> OLD.removed_at) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载'; END IF; END IF; IF OLD.frozen_at IS NOT NULL THEN diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java new file mode 100644 index 000000000..81debbe9f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java @@ -0,0 +1,135 @@ +package com.baiyue.firstleg.customs.container; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Real mapper checks: customs must use physical loading scope, not retained skipped plan rows. */ +class ContainerCustomsActualCargoTest { + private JdbcTemplate jdbc; + private ContainerCustomsMapper mapper; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:customs-loaded-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,plan_status VARCHAR,confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,record_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT,package_count INT,customer_name VARCHAR,order_no VARCHAR,inbound_code VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,product_quantity INT,gross_weight_kg DECIMAL,net_weight_kg DECIMAL,volume_cbm DECIMAL,shipping_mark VARCHAR,customer_remark VARCHAR)"); + jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,customer_name VARCHAR,tax_mode VARCHAR,deleted INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,customs_container_id BIGINT,status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,container_booking_id BIGINT,status VARCHAR,completed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_outbound_dispatch(inbound_id BIGINT,tenant_id BIGINT,container_booking_id BIGINT,status VARCHAR)"); + jdbc.update("INSERT INTO fl_loading_plan VALUES(1,'PLAN',10,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_container_booking VALUES(10,'CAB','CONTAINER','ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1),(200,2)"); + inbound(1, 11, "TAX_REFUND"); + inbound(2, 22, "TAX_REFUND"); + inbound(3, 11, "BUY_DECLARATION"); + inbound(4, 22, "BUY_DECLARATION"); + item(1, 1, 11, 2, "PICKED", 2, 100); + item(2, 1, 11, 1, "PARTIAL", 1, 100); + item(3, 1, 11, 1, "SKIPPED", 0, 100); // same inbound, skipped SKU + item(4, 2, 22, 1, "SKIPPED", 0, 100); // entirely skipped customer/inbound + item(5, 3, 11, 1, "PICKED", 1, 100); + item(6, 4, 22, 1, "SKIPPED", 0, 100); + item(7, 2, 22, 1, "PICKED", 1, 200); // different task/plan cannot prove loading + item(8, 2, 22, 2, "PARTIAL", 1, 100); // unreconciled allocation + item(9, 2, 22, 1, "PICKED", 1, 100); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=9"); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(ContainerCustomsMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(configuration); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ContainerCustomsMapper.class); + } + + @Test + void actualCountsAndInboundScopeUseSettledPicksWithCustomerAndTaxIsolation() { + assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(4); + assertThat(mapper.actualBoxCount(1, 11L, "TAX_REFUND")).isEqualTo(3); + assertThat(mapper.actualBoxCount(1, 22L, "TAX_REFUND")).isZero(); + assertThat(mapper.planInbounds(1)).extracting(ContainerCustomsMapper.InboundRow::inboundId) + .containsExactly(1L, 3L); + assertThat(mapper.completedPlanForOrder(11, 1)).isEqualTo(1); + assertThat(mapper.completedPlanForOrder(22, 2)).isNull(); + } + + @Test + void skippedRefundTicketDoesNotBlockCoverageOrCreateAFalseRefundRequirement() { + assertThat(mapper.taxRefundCount(1)).isEqualTo(1); + assertThat(mapper.missingRefundCount(1, 90)).isEqualTo(1); + jdbc.update("INSERT INTO fl_customs_customer_document VALUES(80,90,'ACTIVE')"); + jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(80,1)"); + assertThat(mapper.missingRefundCount(1, 90)).isZero(); + } + + @Test + void entirelySkippedRefundCargoLeavesARealBuyDeclarationOnlyContainer() { + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id IN (1,2)"); + assertThat(mapper.taxRefundCount(1)).isZero(); + assertThat(mapper.missingRefundCount(1, 90)).isZero(); + assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(1); + assertThat(mapper.planInbounds(1)).singleElement().satisfies(row -> + assertThat(row.taxMode()).isEqualTo("BUY_DECLARATION")); + assertThat(mapper.completedPlanForOrder(11, 1)).isNull(); + } + + @Test + void buyExportCursorCountAndRowsExcludeSkippedCargoConsistently() { + assertThat(mapper.maximumBuyItemId(1)).isEqualTo(5); + assertThat(mapper.countBuyRows(1, 100)).isEqualTo(1); + assertThat(mapper.buyRows(1, 0, 100, 20)).singleElement().satisfies(row -> { + assertThat(row.itemId()).isEqualTo(5); + assertThat(row.packageCount()).isEqualTo(1); + }); + assertThat(mapper.buyRows(1, 5, 100, 20)).isEmpty(); + } + + @Test + void pendingDispatchIgnoresSkippedRowsButStillFindsUndispatchedActualCargo() throws Exception { + jdbc.update("INSERT INTO fl_customs_container VALUES(90,1,10,'COMPLETED',CURRENT_TIMESTAMP)"); + // H2 cannot bind the outer alias inside this LEFT JOIN ON. Bind only that + // correlation to this fixture's container; execute the remaining production SQL unchanged. + String sql = String.join("\n", ContainerCustomsMapper.class.getMethod("pendingAutoDispatch", int.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + .replace("d.container_booking_id=c.container_booking_id", "d.container_booking_id=10") + .replace("#{limit}", "20"); + assertThat(jdbc.queryForList(sql, Long.class)).containsExactly(10L); + jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(1,11,10,'WAITING_FOR_LOADING'),(3,11,10,'WAITING_FOR_LOADING')"); + assertThat(jdbc.queryForList(sql, Long.class)).isEmpty(); + } + + @Test + void preCompletionInboundPreparationKeepsItsPlannedScopeAndNoActualBoxCount() { + jdbc.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=1"); + assertThat(mapper.planInbounds(1)).hasSize(4); + assertThat(mapper.actualBoxCount(1, null, null)).isZero(); + assertThat(mapper.completedPlanForOrder(11, 1)).isNull(); + } + + private void inbound(long id, long tenant, String taxMode) { + jdbc.update("INSERT INTO fl_inbound_order VALUES(?,?,?,'ORDER','INBOUND','TEST',?,0)", id, tenant, id, taxMode); + } + + private void item(long id, long inbound, long tenant, int packages, String status, int picked, long task) { + jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,inbound_id,account_id,deleted,package_count,product_quantity) VALUES(?,1,?,?,0,?,?)", + id, inbound, tenant, packages, packages * 10); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,?)", id, task, status, picked); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java new file mode 100644 index 000000000..a975ee36f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper; +import org.apache.ibatis.annotations.Update; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Persist real SET expressions; adapt only MySQL UPDATE JOIN to H2 MERGE syntax. */ +class PartialLoadingMetricsPersistenceTest { + private JdbcTemplate jdbc; + private String update; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:partial-metrics-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),unit_type VARCHAR,pallet_id BIGINT,deleted INT,updated_by BIGINT,updated_by_name VARCHAR,updated_at TIMESTAMP,version INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,planned_package_count INT,picked_package_count INT,item_status VARCHAR)"); + String mysql = String.join("\n", InboundSplitSettlementMapper.class + .getMethod("convergePartialPlanItem", long.class, long.class, String.class) + .getAnnotation(Update.class).value()); + String assignments = mysql.substring(mysql.indexOf("SET ") + 4, mysql.indexOf("WHERE ")); + String conditions = mysql.substring(mysql.indexOf("WHERE ") + 6); + update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item " + + "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + conditions + + ") THEN UPDATE SET " + assignments) + .replace("#{operatorId}", "9").replace("#{operatorName}", "'TEST'").replace("#{planItemId}", "?"); + } + + @Test + void oneActualCartonUsesItsFrozenUnitsAndHalfTheTwoCartonMetrics() { + seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234"); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + assertMetrics(1, 1, 12, 1, "12.500", "11.000", "9.250", "0.0617"); + } + + @Test + void nonHalfRatioUsesDeclaredUnitsAndDatabasePrecisionWithoutTruncation() { + seed(1, 3, 2, 7, 21, 2, "10.001", "8.002", "6.004", "0.1001"); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + assertMetrics(1, 2, 14, 2, "6.667", "5.335", "4.003", "0.0667"); + } + + @Test + void missingUnitsUsesFrozenQuantityRatioAndPreservesUnknownMeasures() { + seed(1, 3, 2, null, 10, null, null, null, null, null); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + assertMetrics(1, 2, 7, 0, null, null, null, null); + seed(2, 3, 2, null, null, null, null, null, null, null); + assertThat(jdbc.update(update, 2)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT product_quantity FROM fl_loading_plan_item WHERE id=2", Integer.class)).isNull(); + } + + @Test + void alreadyConvergedRowDoesNotScaleAgainOrAdvanceVersion() { + seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234"); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + var after = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1"); + assertThat(jdbc.update(update, 1)).isZero(); + assertThat(jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1")).isEqualTo(after); + } + + @Test + void pendingSkippedFullAndPalletUnitsCannotEnterPartialConvergence() { + for (int id = 1; id <= 4; id++) seed(id, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234"); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='PENDING' WHERE loading_plan_item_id=1"); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id=2"); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='PICKED',picked_package_count=2 WHERE loading_plan_item_id=3"); + jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET',pallet_id=99 WHERE id=4"); + var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id"); + for (int id = 1; id <= 4; id++) assertThat(jdbc.update(update, id)).isZero(); + assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before); + } + + private void seed(long id, int planned, int picked, Integer units, Integer quantity, Integer pallets, + String gross, String net, String dimensional, String volume) { + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(?,?,?,?,?,?,?,?,?,'PACKAGE',NULL,0,NULL,NULL,NULL,0)", + id, planned, quantity, units, pallets, decimal(gross), decimal(net), decimal(dimensional), decimal(volume)); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,'PARTIAL')", id, planned, picked); + } + + private void assertMetrics(long id, int boxes, Integer quantity, Integer pallets, + String gross, String net, String dimensional, String volume) { + var row = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=?", id); + assertThat(row.get("PACKAGE_COUNT")).isEqualTo(boxes); + assertThat(row.get("PRODUCT_QUANTITY")).isEqualTo(quantity); + assertThat(row.get("PALLET_COUNT")).isEqualTo(pallets); + assertThat((BigDecimal) row.get("GROSS_WEIGHT_KG")).isEqualTo(decimal(gross)); + assertThat((BigDecimal) row.get("NET_WEIGHT_KG")).isEqualTo(decimal(net)); + assertThat((BigDecimal) row.get("DIMENSIONAL_WEIGHT_KG")).isEqualTo(decimal(dimensional)); + assertThat((BigDecimal) row.get("VOLUME_CBM")).isEqualTo(decimal(volume)); + } + + private BigDecimal decimal(String value) { return value == null ? null : new BigDecimal(value); } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 7d81e6af5..38984f323 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -16,6 +16,38 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class PdaSplitSettlementSqlContractTest { + @Test + void partialMetricsGuardStaysIdenticalInModuleBaselineAndControlledUpdate() throws Exception { + String module = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + String update = Files.readString(Path.of("..", "deploy", "sql", "migrations", "cn", + "baiyue_firstleg", "V7__settle_partial_loaded_cargo.sql")); + String trigger = loadingItemGuard(module); + assertThat(loadingItemGuard(unified)).isEqualTo(trigger); + assertThat(loadingItemGuard(update)).isEqualTo(trigger); + assertThat(trigger) + .contains("actual_task.loading_plan_id = OLD.loading_plan_id") + .contains("OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL") + .contains("OLD.package_count = actual_pick.planned_package_count") + .contains("actual_pick.picked_package_count < actual_pick.planned_package_count") + .contains("NEW.package_count = actual_pick.picked_package_count") + .contains("NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL") + .contains("NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)") + .contains("NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)") + .contains("IF OLD.frozen_at IS NOT NULL THEN") + .contains("装柜完成后的明细永久冻结") + .contains("NEW.inbound_detail_id <=> OLD.inbound_detail_id"); + } + + private static String loadingItemGuard(String sql) { + int start = sql.indexOf("CREATE TRIGGER trg_loading_item_before_update"); + assertThat(start).isGreaterThanOrEqualTo(0); + int end = sql.indexOf("END$$", start); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end + 5).replaceAll("\\s+", " ").trim(); + } + @Test void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception { String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql") @@ -133,7 +165,7 @@ class PdaSplitSettlementSqlContractTest { .getAnnotation(Update.class).value()[0]; assertThat(converge) .contains("plan_item.pallet_count = CEIL") - .contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)") + .contains("pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)") .contains("plan_item.package_count = pick_item.picked_package_count"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java new file mode 100644 index 000000000..1ae756746 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java @@ -0,0 +1,249 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** Real annotated Mapper query against persisted, not-yet-settled PDA facts. */ +class LoadingCompletedPreflightDatabaseTest { + private JdbcTemplate jdbc; + private SqlSession session; + private LoadingCompletedEventMapper snapshots; + private LoadingCompletedOutboxService service; + private final LoadingPalletLabelService labels = mock(LoadingPalletLabelService.class); + private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); + + @BeforeEach void setup() { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:loading-preflight-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,version INT)"); + jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,container_no VARCHAR,cabinet_no VARCHAR,warehouse_id BIGINT,warehouse_name VARCHAR,estimated_port_arrival_at TIMESTAMP,actual_port_arrival_at TIMESTAMP,estimated_warehouse_arrival_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,root_inbound_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,business_type VARCHAR,fulfillment_type VARCHAR,lx_code VARCHAR,destination_type VARCHAR,destination_warehouse_id BIGINT,destination_code VARCHAR,destination_name VARCHAR,deleted INT)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,customer_name VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),fba_tracking_no VARCHAR,shipment_no VARCHAR,shipping_mark VARCHAR,customer_remark VARCHAR,internal_remark VARCHAR,warehouse_remark VARCHAR,deleted INT,unit_type VARCHAR,pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT,planned_package_count INT)"); + jdbc.execute("ALTER TABLE fl_loading_plan ADD plan_status VARCHAR DEFAULT 'LOADING'"); + jdbc.execute("ALTER TABLE fl_container_booking ADD record_status VARCHAR DEFAULT 'ACTIVE'"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD total_package_count INT"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD pallet_count INT"); + for (String definition : List.of("inbound_code VARCHAR","destination_warehouse_id BIGINT","destination_warehouse_code VARCHAR", + "destination_warehouse_name VARCHAR","load_sequence INT","updated_by BIGINT","updated_by_name VARCHAR","updated_at TIMESTAMP","version INT DEFAULT 0")) { + jdbc.execute("ALTER TABLE fl_loading_plan_item ADD " + definition); + } + jdbc.update("INSERT INTO fl_loading_plan(id,plan_no,container_booking_id,version) VALUES(90,'LP90',91,2)"); + jdbc.update("INSERT INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id,warehouse_name) VALUES(91,'MSCU6639870','D0091',1,'TEST')"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(92,90)"); + Configuration config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds)); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(LoadingCompletedEventMapper.class); + config.addMapper(LoadingPalletLabelMapper.class); + session = new SqlSessionFactoryBuilder().build(config).openSession(true); + snapshots = session.getMapper(LoadingCompletedEventMapper.class); + service = new LoadingCompletedOutboxService(); + ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); + ReflectionTestUtils.setField(service,"cartonManifestMapper",mock(LoadingCartonManifestMapper.class)); + ReflectionTestUtils.setField(service,"palletLabelMapper",mock(LoadingPalletLabelMapper.class)); + ReflectionTestUtils.setField(service,"palletLabelService",labels); + ReflectionTestUtils.setField(service,"outboxMapper",outbox); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + var evidence = mock(CabinetLoadingEvidenceMapper.class); + when(evidence.byContainer(anyLong())).thenReturn(List.of(photo("PHOTO_STEP_1"),photo("PHOTO_STEP_2"),photo("PHOTO_STEP_3"),photo("PHOTO_STEP_4"),photo("SEAL"))); + ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",evidence); + // Unrelated sealed-pallet data is empty; the actual plan and pick rows remain real SQL. + jdbc.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_status VARCHAR,pallet_no VARCHAR)"); + jdbc.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,pallet_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,box_code VARCHAR,actual_sku_quantity INT)"); + jdbc.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,actual_quantity INT)"); + jdbc.execute("CREATE TABLE fl_inbound_pallet_allocation(tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,reservation_id BIGINT,pallet_code VARCHAR,allocation_no INT,picked_package_count INT,allocation_status VARCHAR)"); + for (String definition : List.of("weight_result_id BIGINT","block_id BIGINT","slot_no INT","allocated_package_count INT")) { + jdbc.execute("ALTER TABLE fl_inbound_pallet_allocation ADD " + definition); + } + jdbc.execute("CREATE TABLE fl_inbound_weight_result(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,deleted INT,recognition_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_pallet_label(id BIGINT,loading_plan_id BIGINT,loading_plan_item_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,container_no VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,pallet_index INT,pallet_code VARCHAR)"); + jdbc.execute("CREATE TABLE fl_inbound_carton_label(tenant_id BIGINT,inbound_detail_id BIGINT,batch_status VARCHAR,box_code VARCHAR,carton_index INT,id BIGINT)"); + } + + @AfterEach void close() { session.close(); } + + @Test void allPartialItemsAreValidatedBeforeSettlementWithoutWrites() { + seed(1,11,3,2,"PARTIAL","客户A"); + var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item"); + service.requirePublishable(90); + assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item")).isEqualTo(before); + verify(labels,never()).prepare(anyLong(),anyLong()); + verifyNoInteractions(outbox); + } + + @Test void mixedCustomerPartialCannotDisappearFromReadinessValidation() { + seed(1,11,3,3,"PICKED","客户A"); + seed(2,22,3,2,"PARTIAL",null); + assertThatThrownBy(() -> service.requirePublishable(90)).hasMessageContaining("客户名称快照"); + verify(labels,never()).prepare(anyLong(),anyLong()); + verifyNoInteractions(outbox); + } + + @Test void invalidSecondCustomersRealPartialSnapshotStopsWorkerBeforeAnySettlement() { + seed(1,11,3,3,"PICKED","客户A"); + seed(2,22,3,2,"PARTIAL",null); + var worker = new LoadingPickService(); + var repository = mock(com.baiyue.firstleg.repository.LoadingPickRepository.class); + var settlements = mock(PdaPickSettlementService.class); + var list = new LoadingListService(); + ReflectionTestUtils.setField(list,"loadingCompletedOutboxService",service); + ReflectionTestUtils.setField(worker,"repository",repository); + ReflectionTestUtils.setField(worker,"settlementService",settlements); + ReflectionTestUtils.setField(worker,"loadingListService",list); + ReflectionTestUtils.setField(worker,"inboundHoldGuard",mock(InboundHoldGuard.class)); + ReflectionTestUtils.setField(worker,"cabinetEvidenceService",mock(com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService.class)); + ReflectionTestUtils.setField(worker,"coloadGroups",mock(com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort.class)); + var task = new com.baiyue.firstleg.entity.LoadingPickTaskEntity(); + task.setId(92L); task.setLoadingPlanId(90L); task.setContainerBookingId(91L); + when(repository.task(92L)).thenReturn(task); + when(repository.inboundOwners(92L)).thenReturn(List.of( + new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(11L,1L), + new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(22L,2L))); + var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); + command.setPickTaskId(92L); + assertThatThrownBy(() -> worker.processCompletion(command,"test")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException)error).stage()) + .isEqualTo("COMPLETED_EVENT_READY")); + verifyNoInteractions(settlements,outbox); + } + + @Test void previewUsesFrozenUnitsAndPrecisionButDoesNotScaleSettledRowsTwice() { + seed(1,11,3,2,"PARTIAL","客户A"); + Row row = snapshots.previewRows(90).get(0); + LoadingCompletedOutboxService.projectSettledRow(row); + assertThat(row.getPackageCount()).isEqualTo(2); + assertThat(row.getProductQuantity()).isEqualTo(14); + assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667"); + assertThat(row.getNetWeightKg()).isEqualByComparingTo("5.335"); + assertThat(row.getDimensionalWeightKg()).isEqualByComparingTo("4.003"); + assertThat(row.getVolumeCbm()).isEqualByComparingTo("0.0667"); + assertThat(row.getPalletCount()).isEqualTo(2); + LoadingCompletedOutboxService.projectSettledRow(row); + assertThat(row.getProductQuantity()).isEqualTo(14); + assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667"); + assertThat(jdbc.queryForObject("SELECT package_count FROM fl_loading_plan_item",Integer.class)).isEqualTo(3); + } + + @Test void previewUsesHalfUpFallbackAndKeepsUnknownMetricsNull() { + seed(1,11,3,2,"PARTIAL","客户A"); + jdbc.update("UPDATE fl_loading_plan_item SET units_per_carton=NULL,product_quantity=10,gross_weight_kg=NULL,net_weight_kg=NULL,dimensional_weight_kg=NULL,volume_cbm=NULL"); + Row row = snapshots.previewRows(90).get(0); + LoadingCompletedOutboxService.projectSettledRow(row); + assertThat(row.getProductQuantity()).isEqualTo(7); + assertThat(row.getGrossWeightKg()).isNull(); + assertThat(row.getNetWeightKg()).isNull(); + assertThat(row.getDimensionalWeightKg()).isNull(); + assertThat(row.getVolumeCbm()).isNull(); + } + + @Test void finalPublicationRejectsMixedUnsettledItemsInsteadOfPublishingOnlyFullOnes() { + seed(1,11,3,3,"PICKED","客户A"); + seed(2,22,3,2,"PARTIAL","客户B"); + assertThat(snapshots.rows(90)).hasSize(1); + assertThatThrownBy(() -> service.publish(90)).hasMessageContaining("尚未完成结算"); + verifyNoInteractions(labels,outbox); + } + + @Test void actualSettlementSqlProducesExactlyThePredictedMetricsAndEnablesFinalEvent() throws Exception { + seed(1,11,3,2,"PARTIAL","客户A"); + Row expected = snapshots.previewRows(90).get(0); + LoadingCompletedOutboxService.projectSettledRow(expected); + String sql = String.join("\n", com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.class + .getMethod("convergePartialPlanItem",long.class,long.class,String.class) + .getAnnotation(org.apache.ibatis.annotations.Update.class).value()); + String update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item " + + "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + sql.substring(sql.indexOf("WHERE ") + 6) + + ") THEN UPDATE SET " + sql.substring(sql.indexOf("SET ") + 4,sql.indexOf("WHERE "))) + .replace("#{operatorId}","9").replace("#{operatorName}","'TEST'").replace("#{planItemId}","1"); + assertThat(jdbc.update(update)).isEqualTo(1); + session.clearCache(); + Row settled = snapshots.rows(90).get(0); + assertThat(settled).usingRecursiveComparison().isEqualTo(expected); + service.publish(90); + var published = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.entity.InboundOutboxEntity.class); + verify(outbox).insert(published.capture()); + var json = new ObjectMapper().readTree(published.getValue().getPayloadJson()); + assertThat(json.at("/payload/orders/0/packageCount").asInt()).isEqualTo(2); + assertThat(json.at("/payload/orders/0/productQuantity").asInt()).isEqualTo(14); + assertThat(json.at("/payload/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("6.667"); + } + + @Test void palletPreviewUsesActualScopeAndMatchesSettledFallbackWithoutWritingLabels() { + seed(1,11,3,2,"PARTIAL","客户A"); + seed(2,22,3,3,"SKIPPED","客户B"); + jdbc.update("UPDATE fl_loading_pick_item SET picked_package_count=0 WHERE loading_plan_item_id=2"); + jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1"); + jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED')"); + var labelMapper = session.getMapper(LoadingPalletLabelMapper.class); + var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id"); + var projected = labelMapper.previewSources(90); + assertThat(projected).singleElement().satisfies(row -> { + assertThat(row.getItemId()).isEqualTo(1L); + assertThat(row.getPalletCount()).isEqualTo(2); + }); + assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero(); + jdbc.update("UPDATE fl_loading_plan_item SET package_count=2,pallet_count=0 WHERE id=1"); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=2"); + jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1"); + session.clearCache(); + assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected); + } + + @Test void fallbackPalletPredictionPreservesOriginalInboundDenominatorAfterPartialSettlement() { + seed(1,11,3,1,"PARTIAL","客户A"); + jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1"); + jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED'),(3,11,1,0,'CONFIRMED')"); + var labelMapper = session.getMapper(LoadingPalletLabelMapper.class); + var projected = labelMapper.previewSources(90); + assertThat(projected).singleElement().satisfies(row -> assertThat(row.getPalletCount()).isEqualTo(1)); + // Production settlement changes package_count and resets pallet_count; + // the original declaration total_package_count remains three. + jdbc.update("UPDATE fl_loading_plan_item SET package_count=1,pallet_count=0 WHERE id=1"); + jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1"); + session.clearCache(); + assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero(); + } + + private void seed(long id,long account,int planned,int picked,String status,String customer) { + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,business_type,fulfillment_type,destination_type,destination_warehouse_id,destination_code,destination_name,deleted) VALUES(?,?,?,'FL-TEST','IN-TEST','FBA','STANDARD','FBA',7,'ABE8','TEST',0)",id,account,id); + jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,inbound_detail_id,customer_name,sku_code,product_name_zh,package_count,product_quantity,units_per_carton,pallet_count,gross_weight_kg,net_weight_kg,dimensional_weight_kg,volume_cbm,deleted,unit_type) VALUES(?,90,?,?,? ,?,'SKU-TEST','TEST',?,21,7,2,10.001,8.002,6.004,0.1001,0,'PACKAGE')",id,account,id,id,customer,planned); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,92,?,?,?)",id,id,status,picked,planned); + jdbc.update("UPDATE fl_loading_plan_item SET inbound_code='IN-TEST',destination_warehouse_id=7,destination_warehouse_code='ABE8',destination_warehouse_name='TEST',load_sequence=? WHERE id=?",id,id); + jdbc.update("UPDATE fl_inbound_order SET total_package_count=?,pallet_count=2 WHERE id=?",planned,id); + } + + private static CabinetLoadingEvidenceEntity photo(String type) { + var value = new CabinetLoadingEvidenceEntity(); value.setEvidenceType(type); value.setFileId(1L); + value.setFileName(type+".png"); value.setStatus("ACTIVE"); value.setActiveFlag(1); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java index 892ca2bcf..1ff24eede 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -15,6 +15,29 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class LoadingPalletLabelServiceTest { + @Test + void previewChecksDestinationAndExistingCodesWithoutLocksIdsOrWrites() { + var mapper = mock(LoadingPalletLabelMapper.class); + var service = new LoadingPalletLabelService(); + var warehouses = mock(WarehouseMasterDataClient.class); + ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"warehouseClient",warehouses); + ReflectionTestUtils.setField(service,"internalToken","test-token"); + when(mapper.previewSources(10L)).thenReturn(List.of(source(3,"LAX9",1,"STANDARD"))); + when(warehouses.active("test-token",0L,101L)).thenReturn(warehouse("LAX9","154")); + var preview = service.preview(10L,20L); + assertThat(preview).singleElement().satisfies(row -> { + assertThat(row.getPalletCode()).isEqualTo("D0002-154-001"); + assertThat(row.getLabelId()).isNull(); + }); + org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).lockSources(org.mockito.ArgumentMatchers.anyLong()); + org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).insert(any()); + var wrong = new LoadingPalletLabelMapper.LabelRow(); wrong.setItemId(99L); wrong.setPalletCode("D0002-154-001"); + when(mapper.labels(10L)).thenReturn(List.of(wrong)); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.preview(10L,20L)) + .hasMessageContaining("当前装柜明细不一致"); + } + @Test void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() { var service = org.mockito.Mockito.spy(new LoadingPalletLabelService()); diff --git a/scripts/partial-loading-actual-acceptance-plan.md b/scripts/partial-loading-actual-acceptance-plan.md new file mode 100644 index 000000000..69a14da18 --- /dev/null +++ b/scripts/partial-loading-actual-acceptance-plan.md @@ -0,0 +1,50 @@ +# F21/F22 实装与部分装柜验收方案 + +本文件是本阶段的执行方案,不是已通过的 TEST 证据或状态主文档。不直接运行,不访问环境。真实执行必须等总控确认匹配 Firstleg 版本、受控触发器和独占样本窗口;发布、SQL、数据库取证由总控串行负责。凭据只从本轮指定 TEST 环境文件读取,报告不保存密码、Token 或内部服务令牌。 + +## 入口与样本 + +复用 `admin-test-env-modules/phase3-container-loading.mjs` 的合法订单、审核、测量、分柜、RELEASED 订舱、真实柜号、PDA 领取及五类装柜证据链;复用 `phase3-container-customs-fixture.mjs` 的客户资格和报关入口。现有全拣场景不能直接替代本方案的 PARTIAL/SKIPPED 场景,也不能直接运行内部模块当作新验收入口。 + +使用新批次标记 `PARTIAL-ACTUAL-<运行时间>`、独立客户/订单/柜次/任务。一个任务单请求流,只操作本批次样本;已有柜 `TSTU8545690` 仅做只读回查,不回填计划、库存、入仓单或重放历史事件。失败停在原样本,保留原请求和失败报告,不盲目重造样本。 + +最小场景分两柜,沿合法流程分别准备: + +| 柜 | 来源与操作 | 验证目的 | +|---|---|---| +| A | 客户甲退税普通 PACKAGE 行计划 3 箱、每箱 7 件,实拣 2 箱并提交 PARTIAL;甲另一 SKU 零拣 SKIPPED;客户乙买单普通行全拣;客户丙退税整票零拣 SKIPPED | 混合客户/税制、部分数量缩放、同票跳过 SKU 与整客户跳过隔离 | +| B | 只有买单行实际装柜;同计划退税票全部 SKIPPED | 实际全买单柜不因计划历史退税票要求客户资料或退税发送 | + +从预装详情冻结每行 itemId、客户/来源/明细三键、原箱数、unitsPerCarton、productQuantity、palletCount、重量和体积。件数先按每箱件数×实箱;无每箱件数时按原量比例 HALF_UP 至整数。重量 HALF_UP 至 3 位、体积至 4 位;NULL 保持 NULL。托数按原计划托数比例向上取整。托数为空/0 的标签回退另核对原入仓总箱数、合法确认称重/库位证据,不能用本次实拣总数替换原比例分母。样本中的未知值只来自正式接口允许的输入,不以 SQL 修改事实制造样本。 + +## PDA 正式动作 + +接口基址为 `/api/platform/v1/first-leg/pda-loading`,按正式仓绑定会话访问。 + +1. 回读任务、库存和每行版本;通过既有正式扫码/拣货入口取得真实箱码事实。PARTIAL 为最终实箱数,不能把增量当最终数量。 +2. `POST /tasks/{taskId}/items/{itemId}/partial`:提交 `warehouseId, finalPickedPackageCount, version, operationId`。SKIPPED 使用 `POST /tasks/{taskId}/items/{itemId}/skip`,提交 `warehouseId, version, operationId, reason`,仅限零拣行。 +3. 上传并回查 PHOTO_STEP_1–4 与 SEAL 的五类有效照片和锁码;保留任务及计划全量基线、操作历史、预装冻结行。缺证据拒绝场景优先复用已有证据,不额外销毁照片。 +4. 在最终完成前回读标签,确认预检没有新增标签或占用冻结编号。内部数据库/序号核验由总控做只读取证;不存在公网独立预检写入口。 +5. 回读最新任务版本后,只提交一次 `POST /tasks/{taskId}/complete`,载荷 `expectedTaskVersion, operationId, remark`;保存 HTTP 状态、业务 code、requestId/traceId、完整载荷和命令身份,等待实际 COMPLETED,不把受理态当完成。 +6. 完成后原 operationId、原 expectedTaskVersion、原载荷重放,确认返回同一业务结果;另一独立 operationId 不作为原请求重放。 + +## 完成后的独立核对 + +| 对象 | 必须成立的断言 | +|---|---| +| 完成清单 | `/api/platform/v1/first-leg/loading-lists/{planId}?warehouseId=...` 的行集合严格等于有效实拣集合;PARTIAL 行已结算为实箱,SKIPPED/零拣不出现;列表与详情汇总一致 | +| 计划历史/PDA | 计划历史允许保留 SKIPPED;任务保留真实实拣及原计划数量。不能以原计划总箱数与实装箱数相等作为完成断言 | +| 部分指标 | 原 3 箱 21 件变为实 2 箱 14 件;重量/体积按冻结行缩放。例:10.001/8.002/6.004 kg、0.1001 cbm 对应 6.667/5.335/4.003 kg、0.0667 cbm;实际期望以正式冻结值为准 | +| 来源与余量 | 实箱+余量=原可用箱数;余量沿正式分片产生,原订单/入仓编号及 lineage 可追溯。重放后版本、历史、资源结算和分片数量不重复推进 | +| 标签/事件 | 预测与最终冻结托盘码/托数一致,预检零标签写入;最终 V3 只携带各客户实际货物。总控只读核对唯一 Outbox、载荷摘要、ACK 与接收结果,不伪造事件 | +| 报关平台 | `GET /api/platform/v1/customs/loading-plans/{planId}` 的 actualBoxCount 等于实拣之和;退税票/客户范围、资料覆盖要求排除整票跳过;已实装票缺资料仍拒绝开始 | +| 客户资料 | `/api/merchant/v1/customs/orders/{orderId}`:甲只能见自身已实装退税来源;丙整票跳过没有该柜资料入口;乙买单不会获得退税客户入口;外客户拒绝保持原 code 与诊断字段 | +| 买单导出 | 复用正式导出服务,只含乙实际行,不含跳过的买单行;记录分页最大ID、总数、输出行集合与字节摘要,不能只核对 XLSX 魔数 | +| 全买单 B 柜 | 跳过退税不阻断全买单报关开始/完成;拒绝创建退税申报/发送。报关完成后的自动离仓必须满足既有正式前置,不能用本方案绕过 | +| 下游 | Lastleg/入仓计划/在途库存独立读回与事件实际数一致;禁止把正确下游改成原计划总箱数;原柜只读回查保持下游完整快照不变 | + +## 负向预检与证据界限 + +跨客户第二来源缺客户/目的仓快照、非法 PARTIAL、未结算最终发布等服务内非法事实,使用 `LoadingCompletedPreflightDatabaseTest` 的真实 Mapper 场景核验;不得通过 TEST SQL 捏造非法业务数据。有效新样本的跨客户正常完成必须走上述真实 HTTP。H2 对 MySQL UPDATE JOIN 的语法适配与 pendingAutoDispatch 关联替换均需在报告中披露;真实 MySQL 触发器与最终完成另验。 + +证据建议落在 `reports/partial-loading-actual-<批次>/`:版本/健康与触发器就绪证明、请求序列、before/after 全量快照、逐断言结果、事件/接收端只读取证、失败原件、会话退出结果。执行结束退出自己的全部会话,不删除数据或恢复他人资源。HTTP/DOM证据与人工1920×1080、100%缩放及PDA设备验收分别报告,不据服务测试宣布页面或全流程完成。 -- 2.54.0 From 76f02d8d4554e7a66197f0ab2970604c89dbd1e5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 20:21:16 +0800 Subject: [PATCH 140/176] fix(lastleg): complete stock transfer through linked relabel --- deploy/sql/README.md | 4 +- ...8__bind_stock_transfer_relabel_sources.sql | 14 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 5 +- .../service/InventoryCommandService.java | 76 ++++++- .../service/RelabelApplicationService.java | 16 +- .../dto/WarehouseTransferResponses.java | 2 +- .../WarehouseTransferInventoryAllocation.java | 4 + .../WarehouseTransferRelabelBinding.java | 2 + ...ouseTransferInventoryAllocationMapper.java | 2 +- ...houseTransferRelabelCompletionService.java | 132 ++++++++++++ .../service/WarehouseTransferService.java | 107 ++++++++-- .../src/main/resources/db/lastleg.sql | 8 +- .../openapi/overseas-business-v1.yaml | 53 ++++- .../RelabelApplicationServiceTest.java | 22 ++ ...rehouseTransferRelabelPersistenceTest.java | 190 ++++++++++++++++++ ...ouseTransferRelabelSchemaContractTest.java | 66 ++++++ .../service/WarehouseTransferServiceTest.java | 68 +++++++ 17 files changed, 740 insertions(+), 31 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelSchemaContractTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 69ba2021a..4b3ce062f 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -74,10 +74,10 @@ IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授 | CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 | | CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql`、`V5__event_delivery_retry_audit.sql`、`V6__event_delivery_attempt.sql`、`V7__settle_partial_loaded_cargo.sql` | 收敛一件代发托盘码非空定义;补齐测量/报价、报关、船期和事件重试审计;V7 仅更新装柜明细保护触发器,允许普通箱件按冻结分配和实际拣货精确收敛件数、重量、体积和托数,保留身份及已完成冻结保护,不回填原事件或库存 | | CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | -| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql`、`V6__event_delivery_retry_audit.sql`、`V7__event_delivery_attempt.sql`、`V8__bind_stock_transfer_relabel_sources.sql` | 补齐尾程字段、退货/换标/废弃结构与事件投递审计;V8 保存已有库存转运的实际 SKU、原分配和换标明细关联,允许同位置历史与当前分配共存,保留活动占用唯一约束,不回填旧业务或库存 | | OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 | -上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 +候选编写和离线检查不证明环境已执行;测试环境执行事实只维护在 `../../../docs/测试环境单机部署与配置契约.md`,生产执行须另有明确授权。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=8`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}//`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql new file mode 100644 index 000000000..33672f620 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql @@ -0,0 +1,14 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: bind_stock_transfer_relabel_sources +-- Preconditions: existing transfer allocation/binding tables and original position unique index. +-- Postconditions: exact source SKU and relabel links; active ownership remains unique, history is retained. +-- No business-row backfill, relabel completion fabrication, or inventory mutation. +ALTER TABLE ll_warehouse_transfer_inventory_allocation + ADD COLUMN sku_code VARCHAR(128) NULL COMMENT '本次来源分配的实际SKU快照' AFTER package_count, + DROP INDEX uk_ll_wh_transfer_allocation_position, + ADD KEY idx_ll_wh_transfer_allocation_position (transfer_order_id, inventory_position_id); + +ALTER TABLE ll_warehouse_transfer_relabel_binding + ADD COLUMN source_allocation_id BIGINT NULL COMMENT '已有库存转运的原始来源分配ID' AFTER remark, + ADD COLUMN relabel_item_id BIGINT NULL COMMENT '本票关联换标明细ID' AFTER source_allocation_id; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 459cc0374..925faed59 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -3025,6 +3025,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation ( pallet_id bigint NOT NULL COMMENT '来源托盘ID', pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘号快照', package_count int NOT NULL COMMENT '分配箱数', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次来源分配的实际SKU快照', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', active_position_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的仓位排他键', allocated_at datetime(3) NOT NULL COMMENT '分配时间', @@ -3033,7 +3034,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation ( created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id), + KEY idx_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id), UNIQUE KEY uk_ll_wh_transfer_active_position (customer_id,active_position_key), KEY idx_ll_wh_transfer_allocation_list (customer_id,transfer_order_id,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录'; @@ -3214,6 +3215,8 @@ CREATE TABLE ll_warehouse_transfer_relabel_binding ( fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新FBA箱唛', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA目的仓', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + source_allocation_id bigint DEFAULT NULL COMMENT '已有库存转运的原始来源分配ID', + relabel_item_id bigint DEFAULT NULL COMMENT '本票关联换标明细ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_transfer_relabel_mark (transfer_order_id,customer_carton_mark), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index da23f8550..77e0528e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -36,6 +36,8 @@ import java.util.Objects; @Service public class InventoryCommandService { + private record TransferMoveSnapshot(InventoryCommandRequests.Move move, String sku) {} + private record TransferAdjustmentSnapshot(InventoryCommandRequests.Adjustment adjustment, String sku) {} @Resource private InventoryBalanceMapper balances; @Resource private InventoryPositionMapper positions; @Resource private InventoryLedgerMapper ledgers; @@ -237,7 +239,35 @@ public class InventoryCommandService { @Transactional public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) { - return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> { + return ship(customerId, operatorId, request, null); + } + + @Transactional + public InventoryCommandResponse allocateTransferSnapshot(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request, skuSnapshot); + } + + @Transactional + public InventoryCommandResponse pickTransferSnapshot(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request, skuSnapshot); + } + + /** Exact source snapshot comes from the transfer's locked allocation, not a mixed order aggregate. */ + @Transactional + public InventoryCommandResponse shipTransferSnapshot(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return ship(customerId, operatorId, request, skuSnapshot); + } + + private InventoryCommandResponse ship(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot) { + return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), + skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); @@ -255,7 +285,8 @@ public class InventoryCommandService { balances.updateById(balance); boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0; if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId()); - ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP", + ledger(customerId, operatorId, operation.getId(), balance, position, + skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, "SHIP", "PICKED", "SHIPPED", before, -request.packages(), before - request.packages(), request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), request.idempotencyKey(), request.remark()); @@ -471,10 +502,23 @@ public class InventoryCommandService { @Transactional public InventoryCommandResponse adjust(String customerId, String operatorId, InventoryCommandRequests.Adjustment request) { + return adjust(customerId, operatorId, request, null); + } + + @Transactional + public InventoryCommandResponse adjustTransferRelabel(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return adjust(customerId, operatorId, request, skuSnapshot); + } + + private InventoryCommandResponse adjust(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request, String skuSnapshot) { if (request.packageDelta() == 0) { throw badRequest("Adjustment package delta cannot be zero"); } - return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> { + return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), + skuSnapshot == null ? request : new TransferAdjustmentSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); @@ -492,7 +536,8 @@ public class InventoryCommandService { balance.setAvailablePackages(balanceAfter); touch(balance); balances.updateById(balance); - ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "ADJUST", + ledger(customerId, operatorId, operation.getId(), balance, position, + skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, "ADJUST", "AVAILABLE", "AVAILABLE", before, request.packageDelta(), after, request.businessOrderNo(), null, null, null, request.idempotencyKey(), request.reason() + remarkSuffix(request.remark())); @@ -507,7 +552,25 @@ public class InventoryCommandService { private InventoryCommandResponse move(String customerId, String operatorId, String operationType, Bucket from, Bucket to, InventoryCommandRequests.Move request) { - return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> { + return move(customerId, operatorId, operationType, from, to, request, null); + } + + @Transactional + public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot); + } + + private static void requireTransferSku(String key, String skuSnapshot) { + if (key == null || !key.startsWith("WAREHOUSE_TRANSFER:") || skuSnapshot == null || skuSnapshot.isBlank() + || "MIXED".equals(skuSnapshot)) throw badRequest("Exact warehouse transfer SKU snapshot is required"); + } + + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot) { + return execute(customerId, operatorId, operationType, request.idempotencyKey(), + skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); @@ -524,7 +587,8 @@ public class InventoryCommandService { packages(balance, to, packages(balance, to) + request.packages()); touch(balance); balances.updateById(balance); - ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), operationType, + ledger(customerId, operatorId, operation.getId(), balance, position, + skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, operationType, from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(), request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), request.idempotencyKey(), request.remark()); 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 f91799008..a793cae54 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 @@ -102,6 +102,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private OutboundPdaTaskMapper outboundTasks; @Resource private OverseasFeeSourceService feeSources; @Resource private WarehouseTransferOrderMapper warehouseTransfers; + @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion; @Transactional(readOnly = true) public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); } @@ -812,6 +813,10 @@ public class RelabelApplicationService implements WalletResultHandler { throw conflict("Upload the shipping label before confirming relabel completion"); } List relabelItems = itemList(application.getId()); + WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null + : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + boolean existingStockTransfer = linkedTransfer != null + && StringUtils.hasText(linkedTransfer.getAllocationIdempotencyKey()); boolean requiresOutbound = relabelItems.stream() .anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item))); reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); @@ -827,14 +832,12 @@ public class RelabelApplicationService implements WalletResultHandler { application.setFulfillmentStatus(requiresOutbound ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") : "NOT_REQUIRED"); - if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) { + if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) { createDirectOutbound(application, operatorId, now); application.setFulfillmentStatus("OUTBOUND_CREATED"); } application.setUpdatedAt(now); applications.updateById(application); - WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null - : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); for (LastlegOrder order : tailOrders(application)) { if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; // 仓储转运尾程单由下方按转运目的仓一次性写入精确状态,避免先落入通用状态。 @@ -910,13 +913,18 @@ public class RelabelApplicationService implements WalletResultHandler { && StringUtils.hasText(transfer.getPrivateDestinationAddress()); String transferStatus = transferHasDestination ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + if (existingStockTransfer) { + transferRelabelCompletion.complete(transfer, application, relabelItems, operatorId, now); + transferStatus = "ALLOCATED"; + } transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream() .allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode())); - application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination + application.setFulfillmentStatus(existingStockTransfer ? "WAIT_TRANSFER_OUTBOUND" + : "READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination ? "WAIT_PRE_DISPATCH" : transferStatus); applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 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 882719a4c..3f2148aa8 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 @@ -25,7 +25,7 @@ public final class WarehouseTransferResponses { public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {} public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, Long palletId, String palletNo, int packageCount, String status, - Long destinationInventoryPositionId) {} + Long destinationInventoryPositionId, String skuCode) {} public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String destinationType, String destinationWarehouseCode, String destinationWarehouseName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java index 1133aa030..1c827a3c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.transfer.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import lombok.Data; import java.time.LocalDateTime; @@ -20,7 +22,9 @@ public class WarehouseTransferInventoryAllocation { private Long palletId; private String palletNo; private Integer packageCount; + private String skuCode; private String status; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activePositionKey; private LocalDateTime allocatedAt; private LocalDateTime shippedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java index f87fcca96..01752df72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java @@ -21,5 +21,7 @@ public class WarehouseTransferRelabelBinding { private String fbaCartonMark; private String destinationWarehouseCode; private String remark; + private Long sourceAllocationId; + private Long relabelItemId; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java index 858e80d15..a9da6b5ac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java @@ -11,7 +11,7 @@ import java.util.List; @Mapper public interface WarehouseTransferInventoryAllocationMapper extends BaseMapper { @Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " + - "AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE") + "AND transfer_order_id=#{transferOrderId} AND status<>'RELABELLED' ORDER BY id FOR UPDATE") List lockTransfer(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java new file mode 100644 index 000000000..5b91f86af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java @@ -0,0 +1,132 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** Runs in customer confirmation's transaction; preserves original selections and follows actual pallet moves. */ +@Service +public class WarehouseTransferRelabelCompletionService { + @Resource private WarehouseTransferInventoryAllocationMapper allocations; + @Resource private WarehouseTransferRelabelBindingMapper bindings; + @Resource private InventoryCommandMapper inventoryRows; + @Resource private InventoryCommandService inventory; + private record Target(InventoryBalance balance, InventoryPosition position, int packages, String sku) {} + + public void complete(WarehouseTransferOrder transfer, RelabelApplication application, + List items, String operator, LocalDateTime now) { + String customer = transfer.getCustomerId(); + if (!Objects.equals(application.getCustomerId(), customer) + || !Objects.equals(application.getId(), transfer.getRelabelApplicationId()) + || !Objects.equals(application.getLastlegOrderId(), transfer.getLastlegOrderId()) + || application.getCustomerConfirmedAt() == null) throw conflict("本票换标确认归属不一致"); + List original = allocations.lockTransfer(customer, transfer.getId()); + Map byId = new HashMap<>(); + Map remaining = new HashMap<>(); + for (var row : original) { + if (!"RESERVED".equals(row.getStatus())) throw conflict("转运预留已被其他操作处理"); + byId.put(row.getId(), row); remaining.put(row.getId(), row.getPackageCount()); + } + Map itemById = new HashMap<>(); items.forEach(item -> itemById.put(item.getId(), item)); + var sources = bindings.listOrder(customer, transfer.getId()); + if (sources.isEmpty()) throw conflict("本票换标来源绑定缺失"); + Map targets = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + for (var source : sources) { + var allocation = byId.get(source.getSourceAllocationId()); + RelabelItem item = itemById.get(source.getRelabelItemId()); + if (allocation == null || item == null || !seen.add(item.getId()) + || !Objects.equals(item.getCustomerId(), customer) + || !Objects.equals(item.getApplicationId(), application.getId()) + || !Objects.equals(item.getSourceInboundOrderId(), allocation.getInboundOrderId()) + || !Objects.equals(item.getCartonMark(), source.getCustomerCartonMark()) + || !Objects.equals(item.getSkuCode(), source.getOriginalSku()) + || !Objects.equals(item.getSkuCode(), allocation.getSkuCode())) + throw conflict("本票换标箱未绑定到原转运预留"); + int count = Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0); + int left = remaining.get(allocation.getId()) - count; + if (count <= 0 || left < 0) throw conflict("本票换标数量超过预留来源"); + remaining.put(allocation.getId(), left); + InventoryPosition current = inventoryRows.lockPosition(customer, item.getSourceInventoryPositionId()); + InventoryBalance balance = current == null ? null : inventoryRows.lockBalance(customer, current.getBalanceId()); + if (balance == null || !Objects.equals(balance.getInboundOrderId(), allocation.getInboundOrderId()) + || !Objects.equals(balance.getOverseasWarehouseId(), transfer.getOperatingWarehouseId()) + || current.getHoldPackages() == null || current.getHoldPackages() < count) + throw conflict("本票换标实物位置或冻结数量不一致"); + if (allocations.countActiveDispatchLock(customer, current.getPalletId()) > 0) + throw conflict("本票换标托盘已被排车占用"); + String key = "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL:" + item.getId(); + inventory.releaseTransferRelabel(customer, operator, new InventoryCommandRequests.Move(current.getId(), count, + transfer.getTransferNo(), null, key, key + ":RELEASE", "本票换标客户确认"), item.getSkuCode()); + String outputSku = source.getNewSku() == null ? item.getSkuCode() : source.getNewSku(); + if (!outputSku.equals(item.getSkuCode())) { + inventory.adjustTransferRelabel(customer, operator, new InventoryCommandRequests.Adjustment(current.getId(), -count, + "WAREHOUSE_TRANSFER_RELABEL", transfer.getTransferNo(), key + ":SOURCE", "本票换标旧SKU转出"), item.getSkuCode()); + var output = inventory.stockIn(customer, operator, new InventoryCommandRequests.StockIn(customer, + balance.getCustomerName(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseCode(), + balance.getOverseasWarehouseName(), transfer.getDestinationWarehouseId(), + source.getDestinationWarehouseCode() == null ? balance.getDestinationWarehouseCode() : source.getDestinationWarehouseCode(), + source.getDestinationWarehouseCode() == null ? balance.getDestinationWarehouseName() : source.getDestinationWarehouseCode(), + balance.getInboundOrderId(), balance.getInboundOrderNo(), balance.getInboundCode(), outputSku, + balance.getProductNameZh(), balance.getProductNameEn(), current.getPalletId(), current.getPalletNo(), + key + ":OUTPUT", current.getWarehouseLocationId(), current.getWarehouseLocationCode(), count, + transfer.getTransferNo(), key, key + ":TARGET", "本票换标新SKU转入")); + current = inventoryRows.lockPosition(customer, output.positionId()); + balance = inventoryRows.lockBalance(customer, output.balanceId()); + if (current == null || balance == null || !Objects.equals(current.getBalanceId(), balance.getId()) + || !Objects.equals(balance.getInboundOrderId(), allocation.getInboundOrderId())) + throw conflict("换标新SKU库存事实不一致"); + } + addTarget(targets, balance, current, count, outputSku); + } + if (seen.size() != items.size()) throw conflict("换标明细存在未绑定本票的来源"); + for (var row : original) { + int count = remaining.get(row.getId()); + if (count > 0) { + var current = inventoryRows.lockPosition(customer, row.getInventoryPositionId()); + var balance = inventoryRows.lockBalance(customer, row.getInventoryBalanceId()); + if (current == null || balance == null || current.getHoldPackages() == null || current.getHoldPackages() < count) + throw conflict("未换标的转运余量不可用"); + String key = "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":UNLABELLED:" + row.getId(); + inventory.releaseTransferRelabel(customer, operator, new InventoryCommandRequests.Move(current.getId(), count, + transfer.getTransferNo(), null, key, key + ":RELEASE", "本票未换标余量确认"), row.getSkuCode()); + addTarget(targets, balance, current, count, row.getSkuCode()); + } + row.setStatus("RELABELLED"); row.setActivePositionKey(null); row.setUpdatedAt(now); allocations.updateById(row); + } + if (targets.values().stream().mapToInt(Target::packages).sum() != transfer.getPackageCount()) + throw conflict("换标后转运总箱数不守恒"); + for (Target target : targets.values()) { + var row = new WarehouseTransferInventoryAllocation(); row.setId(IdWorker.getId()); + row.setTransferOrderId(transfer.getId()); row.setCustomerId(customer); + row.setInventoryPositionId(target.position().getId()); row.setInventoryBalanceId(target.balance().getId()); + row.setInboundOrderId(target.balance().getInboundOrderId()); row.setInboundCode(target.balance().getInboundCode()); + row.setPalletId(target.position().getPalletId()); row.setPalletNo(target.position().getPalletNo()); + row.setPackageCount(target.packages()); row.setStatus("RESERVED"); + row.setSkuCode(target.sku()); + row.setActivePositionKey(customer + ":" + target.position().getId()); row.setAllocatedAt(now); + row.setCreatedAt(now); row.setUpdatedAt(now); allocations.insert(row); + } + } + private static void addTarget(Map targets, InventoryBalance balance, InventoryPosition position, int count, String sku) { + Target previous = targets.get(position.getId()); + if (previous != null && !Objects.equals(previous.sku(), sku)) throw conflict("不同SKU换标产出必须保留独立位置批次"); + targets.put(position.getId(), new Target(balance, position, count + (previous == null ? 0 : previous.packages()), sku)); + } + private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); } +} 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 4035b113a..0d13ee870 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 @@ -562,12 +562,13 @@ public class WarehouseTransferService { new LambdaQueryWrapper() .eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId) .eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId()) + .ne(WarehouseTransferInventoryAllocation::getStatus, "RELABELLED") .orderByAsc(WarehouseTransferInventoryAllocation::getId)) .stream().map(value -> { WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId()); return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(), value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), - value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); + value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId(), value.getSkuCode()); }).toList(); List photos = measurementPhotos.listSubmitted(customerId, transfer.getId()) .stream().map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), @@ -1036,7 +1037,7 @@ public class WarehouseTransferService { if (transfer == null) throw notFound("Warehouse transfer order not found"); String key = request.idempotencyKey().trim(); String requestHash = hash(request); - if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) { + if (key.equals(transfer.getAllocationIdempotencyKey())) { if (!requestHash.equals(transfer.getAllocationRequestHash())) { throw conflict("Allocation idempotency key was reused with different inventory positions"); } @@ -1065,6 +1066,7 @@ public class WarehouseTransferService { if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders"); LocalDateTime now = LocalDateTime.now(); + Map> selectedRelabelSources = new java.util.LinkedHashMap<>(); for (WarehouseTransferRequests.Allocation source : requested) { InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId()); if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId()); @@ -1088,6 +1090,7 @@ public class WarehouseTransferService { allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId()); allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId()); allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount()); + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) allocation.setSkuCode(balance.getSkuCode()); allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId()); allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now); try { @@ -1095,12 +1098,42 @@ public class WarehouseTransferService { } catch (DuplicateKeyException exception) { throw conflict("Inventory position is already reserved by another warehouse transfer"); } + if (Boolean.TRUE.equals(transfer.getNeedRelabel()) && "MIXED".equals(balance.getSkuCode())) + throw conflict("聚合库存不能证明本票旧SKU,请选择有明确SKU来源的库存"); + selectedRelabelSources.computeIfAbsent(balance.getSkuCode(), ignored -> new ArrayList<>()) + .add(new ConfirmedStockSource(balance.getId(), position.getId(), source.packageCount(), + balance.getInboundOrderId(), allocation.getId())); } - transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key); + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { + List cargo = transferItems.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())); + if (relabelBindings.listOrder(customerId, transfer.getId()).isEmpty()) + throw conflict("Required transfer relabel instructions are missing"); + RelabelResponses.Detail linked = createLinkedRelabel(customerId, operatorId, transfer, + cargo.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())), + selectedRelabelSources); + transfer.setRelabelApplicationId(linked.application().id()); + transfer.setRelabelNo(linked.application().relabelNo()); + transfer.setStatus(WAIT_RELABEL); + for (var entry : selectedRelabelSources.entrySet()) { + for (ConfirmedStockSource source : entry.getValue()) { + if (source.remainingPackages <= 0) continue; + String holdKey = "WAREHOUSE_TRANSFER:" + transferNo + ":UNLABELLED:" + source.allocationId; + inventory.hold(customerId, operatorId, new InventoryCommandRequests.Move(source.positionId, + source.remainingPackages, transferNo, null, holdKey, holdKey + ":HOLD", "本票转运未换标余量预留")); + } + } + } else transfer.setStatus(ALLOCATED); + transfer.setAllocationIdempotencyKey(key); transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus("WAIT_OUTBOUND"); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:ALLOCATED"); order.setUpdatedAt(now); + String before = order.getCurrentStatus(); order.setCurrentStatus( + Boolean.TRUE.equals(transfer.getNeedRelabel()) ? INBOUND_COMPLETED : "WAIT_OUTBOUND"); + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { + order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW"); + } + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus()); order.setUpdatedAt(now); orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, "Warehouse transfer inventory allocated", now); @@ -1124,6 +1157,14 @@ public class WarehouseTransferService { if (!ALLOCATED.equals(transfer.getStatus())) { throw conflict("Warehouse transfer inventory must be allocated before outbound"); } + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { + RelabelApplication linked = StringUtils.hasText(transfer.getRelabelNo()) + ? relabelApplications.lockNo(customerId, transfer.getRelabelNo()) : null; + if (linked == null || !Objects.equals(linked.getId(), transfer.getRelabelApplicationId()) + || !Objects.equals(linked.getLastlegOrderId(), transfer.getLastlegOrderId()) + || linked.getCustomerConfirmedAt() == null || transfer.getRelabelCompletedAt() == null) + throw conflict("本转运单换标尚未完成客户确认,不可出库"); + } if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { throw conflict("A destination warehouse is required before outbound"); } @@ -1139,13 +1180,21 @@ public class WarehouseTransferService { InventoryCommandRequests.Move move = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); - inventory.allocate(customerId, operatorId, move); - inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move( + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) + inventory.allocateTransferSnapshot(customerId, operatorId, move, allocation.getSkuCode()); + else inventory.allocate(customerId, operatorId, move); + var pick = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, - "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()))); - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark())); + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) + inventory.pickTransferSnapshot(customerId, operatorId, pick, allocation.getSkuCode()); + else inventory.pick(customerId, operatorId, pick); + var ship = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, - "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()))); + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())); + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) + inventory.shipTransferSnapshot(customerId, operatorId, ship, allocation.getSkuCode()); + else inventory.ship(customerId, operatorId, ship); allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); } @@ -1158,6 +1207,10 @@ public class WarehouseTransferService { orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, "Warehouse transfer left source warehouse", now); + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { + RelabelApplication linked = relabelApplications.lockNo(customerId, transfer.getRelabelNo()); + linked.setFulfillmentStatus("OUTBOUNDED"); linked.setUpdatedAt(now); relabelApplications.updateById(linked); + } return detail(customerId, transferNo); } @@ -1200,7 +1253,8 @@ public class WarehouseTransferService { trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()), request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, - sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), + Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(), + sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), request.destinationPalletId(), request.destinationPalletNo().trim(), trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(), trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo, @@ -1225,6 +1279,13 @@ public class WarehouseTransferService { order.setUpdatedAt(now); orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, "Warehouse transfer inventory received at destination", now); + if (complete && Boolean.TRUE.equals(transfer.getNeedRelabel())) { + RelabelApplication linked = relabelApplications.lockNo(customerId, transfer.getRelabelNo()); + if (linked == null || !Objects.equals(linked.getId(), transfer.getRelabelApplicationId())) + throw conflict("本票换标完成归属不一致"); + linked.setStatus("COMPLETED"); linked.setFulfillmentStatus("COMPLETED"); + linked.setUpdatedAt(now); relabelApplications.updateById(linked); + } return detail(customerId, transferNo); } @@ -1616,7 +1677,13 @@ public class WarehouseTransferService { List skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType()) ? List.of(new RelabelRequests.SkuItem(effectiveSku, binding.getNewSku(), binding.getQuantity(), binding.getRemark())) : List.of(); - items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(), + if (stockSource.allocationId != null) { + binding.setOriginalSku(effectiveSku); + binding.setSourceAllocationId(stockSource.allocationId); + relabelBindings.updateById(binding); + } + items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, + stockSource.inboundId == null ? transfer.getInboundOrderId() : stockSource.inboundId, effectiveSku, null, null, null, source.getProductNameZh(), 1, 1, StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(), transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(), @@ -1628,8 +1695,16 @@ public class WarehouseTransferService { transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, firstText(transfer.getRelabelDeliveryMethod(), "TRUCK"), false); - return relabelService.createForExistingTailOrder(customerId, operatorId, + RelabelResponses.Detail created = relabelService.createForExistingTailOrder(customerId, operatorId, transfer.getLastlegOrderId(), request); + for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) { + if (binding.getSourceAllocationId() == null) continue; + RelabelResponses.Item item = created.items().stream() + .filter(value -> Objects.equals(value.cartonMark(), binding.getCustomerCartonMark())) + .findFirst().orElseThrow(() -> conflict("Created relabel did not preserve the transfer carton")); + binding.setRelabelItemId(item.id()); relabelBindings.updateById(binding); + } + return created; } private static ConfirmedStockSource nextStockSource(List sources) { @@ -1647,12 +1722,18 @@ public class WarehouseTransferService { private final Long balanceId; private final Long positionId; private int remainingPackages; + private Long inboundId; + private Long allocationId; private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages) { this.balanceId = balanceId; this.positionId = positionId; this.remainingPackages = remainingPackages; } + private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages, + Long inboundId, Long allocationId) { + this(balanceId, positionId, remainingPackages); this.inboundId = inboundId; this.allocationId = allocationId; + } } private InboundPlan ensureTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 75f0a3803..798829569 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2136,7 +2136,10 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', - remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + remark VARCHAR(1000) NULL COMMENT '备注', + source_allocation_id BIGINT NULL COMMENT '已有库存转运的原始来源分配ID', + relabel_item_id BIGINT NULL COMMENT '本票关联换标明细ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; @@ -2152,6 +2155,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', package_count INT NOT NULL COMMENT '分配箱数', + sku_code VARCHAR(128) NULL COMMENT '本次来源分配的实际SKU快照', status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', @@ -2160,7 +2164,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id), + KEY idx_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id), UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key), KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; 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 c0ce7627f..ededbe554 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1080,6 +1080,57 @@ paths: security: [{ bearerAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }] responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } } + /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/allocations: + post: + summary: 选择已有库存转运的真实来源 + description: 权限lastleg:warehouse-transfer:manage;必须指定customerId。仅APPROVED原票选择精确来源位置和托盘,箱数等于原票箱数。needRelabel=true时按本票relabelBindings创建唯一关联换标并冻结所选数量,返回WAIT_RELABEL和relabelNo;客户完成作业复核确认后回到ALLOCATED,分配追随实际换标位置和新SKU。原idempotencyKey及原载荷重放不重复预留或建換标,同键异载荷冲突。 + security: [{ bearerAuth: [] }] + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey, allocations] + properties: + idempotencyKey: { type: string, maxLength: 255 } + allocations: + type: array + minItems: 1 + items: + type: object + required: [inventoryPositionId, palletId, packageCount] + properties: + inventoryPositionId: { type: integer, format: int64 } + palletId: { type: integer, format: int64 } + packageCount: { type: integer, minimum: 1 } + responses: + '200': { description: 原转运详情;allocations内skuCode为锁定的实际发货SKU,换标后返回新SKU;不包括已被换标替换的历史分配 } + '409': { description: 来源不可用或幂等载荷冲突 } + /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound: + post: + summary: 已有库存转运实际出库 + description: 权限lastleg:warehouse-transfer:manage;必须指定customerId。仅ALLOCATED可执行,需换标本票必须关联本尾程订单的唯一换标申请,实际作业复核及客户确认已完成。按当前分配的新SKU和位置发货并保留唯一SHIP流水,不以MIXED聚合值替代实际SKU。成功原命令重放不重复扣减。 + security: [{ bearerAuth: [] }] + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000 } + responses: + '200': { description: 本票OUTBOUNDED事实或成功原命令重放 } + '409': { description: 尚未完成本票换标客户确认或分配不完整 } /api/merchant/v1/last-leg/warehouse-transfers: get: summary: 客户分页查询自己的仓储转运单 @@ -2840,7 +2891,7 @@ components: deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] } printShippingLabel: { type: boolean } shippingLabelFileId: { type: string, nullable: true } - fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] } + fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, WAIT_TRANSFER_OUTBOUND, READY_FOR_OUTBOUND, OUTBOUND_CREATED, OUTBOUNDED, COMPLETED] } outboundOrderNo: { type: string, nullable: true } customerConfirmedAt: { type: string, format: date-time, nullable: true } reviewChannel: { type: string, enum: [TAIL_ORDER, RELABEL_ORDER], description: TAIL_ORDER 表示客户直接贴标,RELABEL_ORDER 表示其他业务关联换标 } 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 9beb01f8b..d585b8d87 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 @@ -76,6 +76,7 @@ class RelabelApplicationServiceTest { @Mock OutboundPalletItemMapper outboundItems; @Mock OutboundPdaTaskMapper outboundTasks; @Mock WarehouseTransferOrderMapper warehouseTransfers; + @Mock com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion; @Mock OverseasFeeSourceService feeSources; @Mock ReturnOrderMapper returnOrders; @Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks; @@ -93,6 +94,7 @@ class RelabelApplicationServiceTest { }); lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1); ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "transferRelabelCompletion", transferRelabelCompletion); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "orders", orders); @@ -704,6 +706,26 @@ class RelabelApplicationServiceTest { assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus()); } + @Test + void existingStockTransferConfirmationFinalizesOriginalSourcesOnceWithoutIndependentOutbound() { + var application=confirmationApplication("EXPRESS",false);application.setLastlegOrderId(88L); + var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001"); + transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L); + transfer.setAllocationIdempotencyKey("ORIGINAL-SOURCE");transfer.setFbaWarehouseCode("ONT8"); + var transferOrder=order(88,"10001","LMO-TRANSFER-1","WAIT_RELABEL"); + when(applications.lockNo("10001","FBR-1")).thenReturn(application);stubDetail(application); + when(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted"); + service.confirmCompletion("10001","FBR-1","customer-1",command); + assertEquals("ALLOCATED",transfer.getStatus());assertEquals("WAIT_TRANSFER_OUTBOUND",application.getFulfillmentStatus()); + assertEquals("ALLOCATED",transferOrder.getCurrentStatus()); + service.confirmCompletion("10001","FBR-1","customer-1",command); + verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any()); + verify(outboundOrders,never()).insert(any(OutboundOrder.class)); + assertNotNull(transfer.getRelabelCompletedAt()); + } + @Test void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() { RelabelApplication application = confirmationApplication("TRUCK", false); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java new file mode 100644 index 000000000..900859a5f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java @@ -0,0 +1,190 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.transfer.entity.*; +import com.baiyue.lastleg.transfer.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WarehouseTransferRelabelPersistenceTest { + JdbcTemplate jdbc; SqlSessionTemplate session; TransactionTemplate tx; + WarehouseTransferRelabelCompletionService completion; InventoryCommandService inventory; + WarehouseTransferOrder transfer; RelabelApplication application; RelabelItem item; + @BeforeEach void setup() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); tx = new TransactionTemplate(new DataSourceTransactionManager(ds)); + for (Class type : List.of(InventoryBalance.class,InventoryPosition.class,InventoryLedger.class, + InventoryOperation.class,WarehouseTransferInventoryAllocation.class,WarehouseTransferRelabelBinding.class, + WarehouseTransferOrder.class,RelabelApplication.class,RelabelItem.class)) { + var columns = new ArrayList(); + for (var f : type.getDeclaredFields()) { + if(java.lang.reflect.Modifier.isStatic(f.getModifiers()))continue; + TableField field=f.getAnnotation(TableField.class);if(field!=null&&!field.exist())continue; + String name=f.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); + String sqlType=f.getType()==String.class?"VARCHAR(2048)":f.getType()==LocalDateTime.class?"TIMESTAMP":f.getType()==Boolean.class?"BOOLEAN":"BIGINT"; + columns.add(name+" "+sqlType+(name.equals("id")?" PRIMARY KEY":"")); + } + jdbc.execute("CREATE TABLE "+type.getAnnotation(TableName.class).value()+" ("+String.join(",",columns)+")"); + } + jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(customer_id VARCHAR(100),warehouse_pallet_id BIGINT,status VARCHAR(40))"); + jdbc.execute("CREATE UNIQUE INDEX allocation_active ON ll_warehouse_transfer_inventory_allocation(customer_id,active_position_key)"); + jdbc.execute("CREATE INDEX idx_ll_wh_transfer_allocation_position ON ll_warehouse_transfer_inventory_allocation(transfer_order_id,inventory_position_id)"); + jdbc.execute("CREATE UNIQUE INDEX operation_key ON ll_inventory_operation(customer_id,idempotency_key)"); + var configuration=new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + for(Class mapper:List.of(InventoryBalanceMapper.class,InventoryPositionMapper.class,InventoryLedgerMapper.class, + InventoryOperationMapper.class,InventoryCommandMapper.class,WarehouseTransferInventoryAllocationMapper.class, + WarehouseTransferRelabelBindingMapper.class,WarehouseTransferOrderMapper.class, + com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class, + com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class))configuration.addMapper(mapper); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration); + session=new SqlSessionTemplate(factory.getObject()); + inventory=new InventoryCommandService(); + ReflectionTestUtils.setField(inventory,"balances",session.getMapper(InventoryBalanceMapper.class)); + ReflectionTestUtils.setField(inventory,"positions",session.getMapper(InventoryPositionMapper.class)); + ReflectionTestUtils.setField(inventory,"ledgers",session.getMapper(InventoryLedgerMapper.class)); + ReflectionTestUtils.setField(inventory,"operations",session.getMapper(InventoryOperationMapper.class)); + ReflectionTestUtils.setField(inventory,"commands",session.getMapper(InventoryCommandMapper.class)); + ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(WarehouseResourceGateway.class)); + ReflectionTestUtils.setField(inventory,"objectMapper",new ObjectMapper().findAndRegisterModules()); + completion=new WarehouseTransferRelabelCompletionService(); + ReflectionTestUtils.setField(completion,"allocations",session.getMapper(WarehouseTransferInventoryAllocationMapper.class)); + ReflectionTestUtils.setField(completion,"bindings",session.getMapper(WarehouseTransferRelabelBindingMapper.class)); + ReflectionTestUtils.setField(completion,"inventoryRows",session.getMapper(InventoryCommandMapper.class)); + ReflectionTestUtils.setField(completion,"inventory",inventory); + transfer=new WarehouseTransferOrder();transfer.setId(1L);transfer.setTransferNo("WT-ORIGINAL");transfer.setCustomerId("7"); + transfer.setPackageCount(1);transfer.setOperatingWarehouseId("9");transfer.setLastlegOrderId(10L);transfer.setRelabelApplicationId(11L); + application=new RelabelApplication();application.setId(11L);application.setCustomerId("7");application.setLastlegOrderId(10L); + application.setCustomerConfirmedAt(LocalDateTime.now()); + item=new RelabelItem();item.setId(12L);item.setApplicationId(11L);item.setCustomerId("7");item.setSkuCode("OLD"); + item.setSourceInventoryPositionId(202L);item.setSourceInboundOrderId(50L);item.setCartonMark("BOX-1");item.setNotifiedPackageCount(1); + jdbc.update("INSERT INTO ll_inventory_balance(id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,in_transit_packages,procurement_packages,version) VALUES (60,'7','Synthetic customer','9','SRC','Source','ABE4',50,'IP-50','IN-50','OLD',2,2,0,0,0,0,0,0)"); + position(101,701,2,0,"SOURCE");position(202,702,0,1,"MOVED");position(99,799,0,1,"OTHER-HOLD"); + var allocation=new WarehouseTransferInventoryAllocation();allocation.setId(13L);allocation.setCustomerId("7");allocation.setTransferOrderId(1L); + allocation.setInventoryPositionId(101L);allocation.setInventoryBalanceId(60L);allocation.setInboundOrderId(50L); + allocation.setPackageCount(1);allocation.setStatus("RESERVED");allocation.setPalletId(701L);allocation.setActivePositionKey("7:101"); + allocation.setSkuCode("OLD"); + session.getMapper(WarehouseTransferInventoryAllocationMapper.class).insert(allocation); + var binding=new WarehouseTransferRelabelBinding();binding.setId(14L);binding.setCustomerId("7");binding.setTransferOrderId(1L); + binding.setSourceAllocationId(13L);binding.setRelabelItemId(12L);binding.setOriginalSku("OLD");binding.setNewSku("NEW");binding.setCustomerCartonMark("BOX-1"); + session.getMapper(WarehouseTransferRelabelBindingMapper.class).insert(binding); + } + void position(long id,long pallet,long available,long hold,String batch){jdbc.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,pallet_id,pallet_no,batch_no,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES (?,'7',60,'9','ABE4',?,?,?, ?,?,0,0,0,0)",id,pallet,"P-"+pallet,batch,available,hold);} + @Test void confirmsMovedCartonIntoNewSkuAndPreservesOtherHoldWithRealCommandsAndMappers() { + tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())); + assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class)); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class)); + assertEquals(2L,jdbc.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=101",Long.class)); + var rows=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L); + assertEquals(1,rows.size());assertEquals(702L,rows.get(0).getPalletId());assertEquals(1,rows.get(0).getPackageCount()); + assertEquals("NEW",rows.get(0).getSkuCode()); + assertEquals("NEW",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_ledger WHERE business_type='STOCK_IN'",String.class)); + } + @Test void missingExactCartonBindingRollsBackWithoutInventoryOrReservationMutation(){ + item.setId(999L); + assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()))); + assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class)); + } + @Test void lateAllocationFailureRollsBackRealReleaseSkuOutputAndOriginalReservation(){ + jdbc.update("INSERT INTO ll_warehouse_transfer_inventory_allocation(id,customer_id,transfer_order_id,inventory_position_id,active_position_key,status) VALUES (900,'7',999,901,'7:901','RESERVED')"); + // The target ID is allocated dynamically; inject a failure after real stock changes via the source mapper. + var mapper=org.mockito.Mockito.spy(session.getMapper(WarehouseTransferInventoryAllocationMapper.class)); + org.mockito.Mockito.doThrow(new IllegalStateException("FORCED_FINAL_ALLOCATION_FAILURE")).when(mapper).insert(org.mockito.ArgumentMatchers.any(WarehouseTransferInventoryAllocation.class)); + ReflectionTestUtils.setField(completion,"allocations",mapper); + assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()))); + assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=202",Long.class)); + assertEquals("OLD",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_balance WHERE id=60",String.class)); + assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class)); + } + @Test void shipsActualNewSkuFromMixedBalanceAndOriginalKeysReplayWithoutSecondDecrement(){ + tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())); + var actual=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L).get(0); + assertEquals("MIXED",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_balance WHERE id=60",String.class)); + var allocate=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:ALLOCATE",""); + var pick=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:PICK",""); + var ship=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:SHIP",""); + tx.executeWithoutResult(s->{inventory.allocateTransferSnapshot("7","operator",allocate,actual.getSkuCode());inventory.pickTransferSnapshot("7","operator",pick,actual.getSkuCode());inventory.shipTransferSnapshot("7","operator",ship,actual.getSkuCode());}); + tx.executeWithoutResult(s->{assertTrue(inventory.allocateTransferSnapshot("7","operator",allocate,"NEW").replayed());assertTrue(inventory.pickTransferSnapshot("7","operator",pick,"NEW").replayed());assertTrue(inventory.shipTransferSnapshot("7","operator",ship,"NEW").replayed());}); + assertEquals(3L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class)); + assertEquals(1L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type='SHIP' AND sku_code='NEW'",Long.class)); + assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type IN ('SHIP','PICK','ALLOCATE') AND sku_code<>'NEW'",Long.class)); + assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->inventory.shipTransferSnapshot("7","operator",ship,"OLD"))); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class)); + } + @Test void cartonOnlyConfirmationKeepsSamePositionAndRetainsOriginalBindingHistory(){ + jdbc.update("UPDATE ll_warehouse_transfer_relabel_binding SET new_sku=NULL WHERE id=14"); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=1 WHERE id=101"); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=0 WHERE id=202");item.setSourceInventoryPositionId(101L); + tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())); + var current=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L); + assertEquals(1,current.size());assertEquals(101L,current.get(0).getInventoryPositionId());assertEquals("OLD",current.get(0).getSkuCode()); + assertEquals(2L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation WHERE inventory_position_id=101",Long.class)); + assertEquals("RELABELLED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class)); + assertEquals(13L,jdbc.queryForObject("SELECT source_allocation_id FROM ll_warehouse_transfer_relabel_binding WHERE id=14",Long.class)); + assertEquals(12L,jdbc.queryForObject("SELECT relabel_item_id FROM ll_warehouse_transfer_relabel_binding WHERE id=14",Long.class)); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class)); + } + @Test void partiallyRelabeledTransferRetainsUnchangedOriginalRemainderAsSeparateCurrentAllocation(){ + transfer.setPackageCount(2);jdbc.update("UPDATE ll_warehouse_transfer_inventory_allocation SET package_count=2 WHERE id=13"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=1,hold_packages=1 WHERE id=101"); + jdbc.update("UPDATE ll_inventory_balance SET available_packages=1,hold_packages=3 WHERE id=60"); + tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())); + var current=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L); + assertEquals(2,current.size());assertEquals(2,current.stream().mapToInt(WarehouseTransferInventoryAllocation::getPackageCount).sum()); + assertEquals(Set.of("OLD","NEW"),new HashSet<>(current.stream().map(WarehouseTransferInventoryAllocation::getSkuCode).toList())); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class)); + assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class)); + } + @Test void customerConfirmationReplaysPersistedKeyWithoutSecondInventoryOrAllocationWrites(){ + transfer.setStatus("WAIT_RELABEL");transfer.setAllocationIdempotencyKey("SELECT-ORIGINAL");transfer.setRelabelNo("RL-OWN"); + transfer.setFbaWarehouseCode("ABE4");transfer.setDestinationType("FBA");transfer.setDestinationWarehouseCode("ABE4"); + application.setRelabelNo("RL-OWN");application.setStatus("CUSTOMER_CONFIRM_PENDING");application.setDeliveryMethod("TRUCK"); + application.setCustomerConfirmedAt(null);item.setOperationType("CARTON_AND_SKU"); + session.getMapper(WarehouseTransferOrderMapper.class).insert(transfer); + var apps=session.getMapper(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);apps.insert(application); + var items=session.getMapper(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);items.insert(item); + var service=spy(new com.baiyue.lastleg.relabel.service.RelabelApplicationService()); + ReflectionTestUtils.setField(service,"applications",apps);ReflectionTestUtils.setField(service,"items",items); + ReflectionTestUtils.setField(service,"warehouseTransfers",session.getMapper(WarehouseTransferOrderMapper.class)); + ReflectionTestUtils.setField(service,"transferRelabelCompletion",completion); + var tasks=mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var task=new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();task.setStatus("WAIT_REVIEW");task.setVersion(1); + when(tasks.lock("7",11L)).thenReturn(task);when(tasks.updateWithVersion(any(),any())).thenReturn(1); + ReflectionTestUtils.setField(service,"pdaTasks",tasks); + ReflectionTestUtils.setField(service,"returnOrders",mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper.class)); + ReflectionTestUtils.setField(service,"histories",mock(com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper.class)); + ReflectionTestUtils.setField(service,"orderHistories",mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class)); + var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var order=new com.baiyue.lastleg.entity.LastlegOrder(); + order.setId(10L);order.setCustomerId("7");order.setCurrentStatus("WAIT_RELABEL");order.setOrderType("WAREHOUSE_TRANSFER"); + when(orders.selectList(any())).thenReturn(List.of(order));when(orders.selectById(10L)).thenReturn(order); + ReflectionTestUtils.setField(service,"orders",orders);doReturn(null).when(service).detail("7","RL-OWN"); + var command=new com.baiyue.lastleg.relabel.dto.RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","confirmed"); + tx.executeWithoutResult(s->service.confirmCompletion("7","RL-OWN","operator",command)); + long ledgers=jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class); + long allocations=jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation",Long.class); + tx.executeWithoutResult(s->service.confirmCompletion("7","RL-OWN","operator",command)); + assertEquals(ledgers,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertEquals(allocations,jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation",Long.class)); + assertEquals("ALLOCATED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_order WHERE id=1",String.class)); + assertEquals("CONFIRM-ORIGINAL",jdbc.queryForObject("SELECT customer_confirmation_key FROM ll_relabel_application WHERE id=11",String.class)); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelSchemaContractTest.java new file mode 100644 index 000000000..f584e923d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelSchemaContractTest.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.transfer.service; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseTransferRelabelSchemaContractTest { + private List baselines() throws Exception { + return List.of(new ClassPathResource("db/lastleg.sql").getContentAsString(StandardCharsets.UTF_8), + Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"))); + } + + @Test + void bothBaselinesPersistExactAllocationSkuAndOriginalRelabelLinks() throws Exception { + for (String sql : baselines()) { + assertThat(table(sql, "ll_warehouse_transfer_inventory_allocation").toLowerCase()) + .contains("sku_code varchar(128)"); + assertThat(table(sql, "ll_warehouse_transfer_relabel_binding").toLowerCase()) + .contains("source_allocation_id bigint", "relabel_item_id bigint") + .contains("uk_ll_transfer_relabel_mark"); + } + } + + @Test + void historyCanSharePositionWhileOnlyOneActiveAllocationOwnsIt() throws Exception { + for (String sql : baselines()) { + String allocation = table(sql, "ll_warehouse_transfer_inventory_allocation") + .replaceAll("\\s+", "").toLowerCase(); + assertThat(allocation) + .contains("keyidx_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id)") + .contains("uniquekeyuk_ll_wh_transfer_active_position(customer_id,active_position_key)") + .doesNotContain("uniquekeyuk_ll_wh_transfer_allocation_position"); + } + } + + @Test + void controlledUpdateOnlyChangesTwoStructuresWithoutRepairingBusinessFacts() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "migrations", "overseas", + "baiyue_lastleg", "V8__bind_stock_transfer_relabel_sources.sql")) + .replaceAll("(?m)^--.*$", "").toLowerCase(); + assertThat(sql.split(";")).filteredOn(part -> !part.isBlank()).hasSize(2); + assertThat(sql) + .contains("alter table ll_warehouse_transfer_inventory_allocation", + "alter table ll_warehouse_transfer_relabel_binding", "add column sku_code varchar(128) null", + "add column source_allocation_id bigint null", "add column relabel_item_id bigint null", + "drop index uk_ll_wh_transfer_allocation_position", + "add key idx_ll_wh_transfer_allocation_position") + .doesNotContain("drop index uk_ll_wh_transfer_active_position", "drop table", "truncate", + "insert into", "update ll_", "delete from", "disable keys"); + } + + private static String table(String sql, String name) { + int start = sql.indexOf("CREATE TABLE " + name + " ("); + if (start < 0) start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + name + " ("); + assertThat(start).as(name).isGreaterThanOrEqualTo(0); + int end = sql.indexOf(") ENGINE", start); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } +} 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 961ab1fd1..113176d0b 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 @@ -170,6 +170,7 @@ class WarehouseTransferServiceTest { @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @Mock InventoryCommandService inventory; @Mock InventoryCommandMapper inventoryCommands; + @Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService; private WarehouseTransferService service; @BeforeEach @@ -201,6 +202,7 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); + ReflectionTestUtils.setField(service, "relabelService", relabelService); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().when(orderNoGenerator.next(anyString(), any(), anyLong())) .thenReturn("WT-WH-20260901-000001"); @@ -791,6 +793,72 @@ class WarehouseTransferServiceTest { verifyNoInteractions(inventory); } + @Test + void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + transfer.setNeedRelabel(true); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-RELABEL", "must wait"))); + verifyNoInteractions(inventory); + } + + @Test + void choosingOriginalStockStartsItsOwnRelabelAndReplaysWithoutSecondApplicationOrHold() { + var transfer = transfer("APPROVED"); transfer.setNeedRelabel(true); transfer.setPackageCount(1); + var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding(); + binding.setId(80L); binding.setCustomerCartonMark("BOX-1");binding.setOriginalSku("SKU-TRANSFER"); + binding.setNewSku("NEW");binding.setOperationType("CARTON_AND_SKU");binding.setQuantity(1); + var cargo = new com.baiyue.lastleg.transfer.entity.WarehouseTransferItem();cargo.setId(90L);cargo.setSkuCode("SKU-TRANSFER");cargo.setPackageCount(1); + var summary = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.dto.RelabelResponses.Summary.class); + when(summary.id()).thenReturn(42L);when(summary.relabelNo()).thenReturn("RL-OWN"); + var carton = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.dto.RelabelResponses.Item.class); + when(carton.id()).thenReturn(43L);when(carton.cartonMark()).thenReturn("BOX-1"); + var created = new com.baiyue.lastleg.relabel.dto.RelabelResponses.Detail(summary,"",List.of(carton),List.of(),List.of(),null); + when(relabelService.createForExistingTailOrder(eq("TENANT-A"),eq("USER-1"),eq(31L),any())).thenReturn(created); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder())); + when(inventoryCommands.lockPosition("TENANT-A",501L)).thenReturn(position(501,601,701,3)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601,101,"IN-1")); + when(transferItems.selectList(any())).thenReturn(List.of(cargo)); + when(relabelBindings.listOrder("TENANT-A",21L)).thenReturn(List.of(binding)); + when(orders.selectById(31L)).thenReturn(tailOrder()); + var command=new WarehouseTransferRequests.Allocate("ORIGINAL-SELECT",List.of(new WarehouseTransferRequests.Allocation(501L,701L,1))); + var response=service.allocate("TENANT-A","WTR-1","USER-1",command); + assertEquals("WAIT_RELABEL",response.status());assertEquals("RL-OWN",transfer.getRelabelNo()); + assertEquals(43L,binding.getRelabelItemId());assertNotNull(binding.getSourceAllocationId()); + service.allocate("TENANT-A","WTR-1","USER-1",command); + verify(relabelService).createForExistingTailOrder(eq("TENANT-A"),eq("USER-1"),eq(31L),any()); + verify(inventoryAllocations).insert(any(WarehouseTransferInventoryAllocation.class)); + verifyNoInteractions(inventory); + assertThrows(ResponseStatusException.class,()->service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("EARLY",""))); + } + + @Test + void confirmedOwnRelabelShipsAndReceivesItsNewSkuAndCompletesLinkedFulfillment() { + var transfer=transfer("ALLOCATED");transfer.setNeedRelabel(true);transfer.setRelabelNo("RL-OWN"); + transfer.setRelabelApplicationId(42L);transfer.setRelabelCompletedAt(LocalDateTime.now()); + var app=new RelabelApplication();app.setId(42L);app.setLastlegOrderId(31L);app.setCustomerConfirmedAt(LocalDateTime.now()); + var current=allocation();current.setSkuCode("NEW"); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(relabelApplications.lockNo("TENANT-A","RL-OWN")).thenReturn(app); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(current)); + when(orders.selectById(31L)).thenReturn(tailOrder());when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed")); + service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed")); + verify(inventory).shipTransferSnapshot(eq("TENANT-A"),eq("USER-1"),any(),eq("NEW")); + verify(inventory,never()).ship(anyString(),anyString(),any());assertEquals("OUTBOUNDED",app.getFulfillmentStatus()); + var mixed=balance(601,101,"IN-1");mixed.setSkuCode("MIXED");when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(mixed); + when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn(new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false)); + service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); + var received=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("TENANT-A"),eq("USER-1"),received.capture());assertEquals("NEW",received.getValue().skuCode()); + assertEquals("RECEIVED",transfer.getStatus());assertEquals("COMPLETED",app.getStatus());assertEquals("COMPLETED",app.getFulfillmentStatus()); + } + @Test void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); -- 2.54.0 From 1302275b715a8391c1b857d4af8c83e037794ef2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 20:29:30 +0800 Subject: [PATCH 141/176] docs(lastleg): group transfer operations with platform contracts --- .../openapi/overseas-business-v1.yaml | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) 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 ededbe554..82472280f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1034,52 +1034,6 @@ paths: required: true content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } - /api/merchant/v1/last-leg/delivery-orders: - get: - summary: 客户分页查询自己的派送单 - description: 权限 merchant:warehouse:delivery:list;客户范围只取认证 Token,不接收 customerId。 - security: [{ bearerAuth: [] }] - parameters: - - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - - { name: documentNo, in: query, schema: { type: string } } - - { name: warehouseId, in: query, schema: { type: string } } - - { name: status, in: query, schema: { type: string } } - - { name: dispatchNo, in: query, schema: { type: string } } - - { name: waybillNo, in: query, schema: { type: string } } - - { name: vehiclePlate, in: query, schema: { type: string } } - - { name: driverName, in: query, schema: { type: string } } - - { name: deliveryMethod, in: query, schema: { type: string } } - - { name: plannedFrom, in: query, schema: { type: string, format: date-time } } - - { name: plannedTo, in: query, schema: { type: string, format: date-time } } - responses: { '200': { description: 当前客户派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } }, '403': { description: 缺少客户派送查看权限 } } - post: - summary: 客户从自己的排车单创建派送单 - description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } - responses: { '200': { description: 出库与派送单转换结果 }, '403': { description: 缺少客户派送创建权限 }, '404': { description: 当前客户下排车单不存在 }, '409': { description: 状态、参数或幂等冲突 } } - /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}: - get: - summary: 客户查询自己的派送单详情 - description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。 - security: [{ bearerAuth: [] }] - parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 当前客户派送单详情, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderDetailResponse' } } } }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } - /api/merchant/v1/last-leg/orders: - get: - summary: 客户分页查询自己的海外尾程订单 - description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。 - security: [{ bearerAuth: [] }] - responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } } - /api/merchant/v1/last-leg/orders/{id}: - get: - summary: 客户查询自己的海外尾程订单详情 - description: 权限 merchant:overseas-warehouse:last-mile-order:list;不泄露其他客户订单。 - security: [{ bearerAuth: [] }] - parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } } /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/allocations: post: summary: 选择已有库存转运的真实来源 @@ -1131,6 +1085,52 @@ paths: responses: '200': { description: 本票OUTBOUNDED事实或成功原命令重放 } '409': { description: 尚未完成本票换标客户确认或分配不完整 } + /api/merchant/v1/last-leg/delivery-orders: + get: + summary: 客户分页查询自己的派送单 + description: 权限 merchant:warehouse:delivery:list;客户范围只取认证 Token,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: documentNo, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: dispatchNo, in: query, schema: { type: string } } + - { name: waybillNo, in: query, schema: { type: string } } + - { name: vehiclePlate, in: query, schema: { type: string } } + - { name: driverName, in: query, schema: { type: string } } + - { name: deliveryMethod, in: query, schema: { type: string } } + - { name: plannedFrom, in: query, schema: { type: string, format: date-time } } + - { name: plannedTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 当前客户派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } }, '403': { description: 缺少客户派送查看权限 } } + post: + summary: 客户从自己的排车单创建派送单 + description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } + responses: { '200': { description: 出库与派送单转换结果 }, '403': { description: 缺少客户派送创建权限 }, '404': { description: 当前客户下排车单不存在 }, '409': { description: 状态、参数或幂等冲突 } } + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}: + get: + summary: 客户查询自己的派送单详情 + description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户派送单详情, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderDetailResponse' } } } }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } + /api/merchant/v1/last-leg/orders: + get: + summary: 客户分页查询自己的海外尾程订单 + description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } } + /api/merchant/v1/last-leg/orders/{id}: + get: + summary: 客户查询自己的海外尾程订单详情 + description: 权限 merchant:overseas-warehouse:last-mile-order:list;不泄露其他客户订单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }] + responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } } /api/merchant/v1/last-leg/warehouse-transfers: get: summary: 客户分页查询自己的仓储转运单 -- 2.54.0 From ea9732749a9215e660530035c40afc69fe56a5ae Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 20:45:01 +0800 Subject: [PATCH 142/176] fix(firstleg): finish only actual picked cargo owners --- .../mapper/LoadingPickItemMapper.java | 15 +++ .../repository/LoadingPickRepository.java | 4 + .../firstleg/service/LoadingPickService.java | 4 +- ...LoadingCompletionOwnerPersistenceTest.java | 95 ++++++++++++++++ ...LoadingCompletedPreflightDatabaseTest.java | 2 +- .../service/LoadingPickServiceTest.java | 102 ++++++++++++++++-- 6 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 5ce20ed4c..e29fbfcfe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -165,6 +165,21 @@ public interface LoadingPickItemMapper extends BaseMapper """) java.util.List selectInboundOwners(@Param("taskId") long taskId); + /** Only physical cargo owns completion checkpoints; retained SKIPPED rows are plan history. */ + @Select(""" + SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId + FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id + AND plan_item.loading_plan_id=task.loading_plan_id AND plan_item.deleted=0 + WHERE pick_item.pick_task_id=#{taskId} + AND pick_item.item_status IN ('PICKED','PARTIAL') + AND pick_item.picked_package_count>0 + GROUP BY plan_item.account_id,plan_item.inbound_id + ORDER BY plan_item.account_id,plan_item.inbound_id + """) + java.util.List selectCompletionOwners(@Param("taskId") long taskId); + record InboundOwner(Long tenantId, Long inboundId) {} @Select(""" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 51260d8c9..8e9422623 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -246,6 +246,10 @@ public class LoadingPickRepository { return items.selectInboundOwners(taskId); } + public List completionOwners(long taskId) { + return items.selectCompletionOwners(taskId); + } + public LoadingInboundMapper.Snapshot inbound(long tenantId, long inboundId) { return inbounds.find(tenantId, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index c4c5d2571..e123514c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -576,7 +576,7 @@ public class LoadingPickService { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成"); - List discoveredOwners = repository.inboundOwners(taskId).stream() + List discoveredOwners = repository.completionOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) .toList(); if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); @@ -618,7 +618,7 @@ public class LoadingPickService { long taskId = command.getPickTaskId(); inboundHoldGuard.requireTaskNotHeld(taskId); LoadingPickTaskEntity task = requiredTask(taskId); - List owners = repository.inboundOwners(taskId).stream() + List owners = repository.completionOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java new file mode 100644 index 000000000..072f61b37 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java @@ -0,0 +1,95 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Execute actual owner and checkpoint Mapper SQL against persisted resolved pick facts. */ +class LoadingCompletionOwnerPersistenceTest { + private JdbcTemplate jdbc; + private LoadingPickItemMapper owners; + private LoadingCompletionCommandMapper checkpoints; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:completion-owners-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)"); + row(1, 101, 50, 3, "PARTIAL", 2, 10, 0); + row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history + row(3, 202, 60, 1, "PICKED", 1, 10, 0); + row(4, 303, 70, 1, "SKIPPED", 0, 10, 0); // whole skipped customer + row(5, 404, 80, 1, "PICKED", 0, 10, 0); // nonphysical count + row(6, 505, 90, 1, "PICKED", 1, 10, 1); // historical deleted cargo + row(7, 606, 100, 1, "PICKED", 1, 999, 0); // wrong plan cannot prove loading + row(8, 707, 110, 1, "PENDING", 1, 10, 0); // unresolved status + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(LoadingPickItemMapper.class); + config.addMapper(LoadingCompletionCommandMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var session = new SqlSessionTemplate(factory.getObject()); + owners = session.getMapper(LoadingPickItemMapper.class); + checkpoints = session.getMapper(LoadingCompletionCommandMapper.class); + } + + @Test + void unreconciledPartialAndFullCargoOwnCompletionButSkippedAndInvalidScopeDoNot() { + assertThat(owners.selectCompletionOwners(20)).containsExactly(new InboundOwner(101L, 50L), new InboundOwner(202L, 60L)); + // Planned owner lookup still serves claims, holds and plan operations; its scope is not weakened. + assertThat(owners.selectInboundOwners(20)).contains(new InboundOwner(303L, 70L)); + } + + @Test + void partialSettlementAndSkippedHistoryRemovalCannotChangeActualOwnerSequence() { + List before = owners.selectCompletionOwners(20); + jdbc.update("UPDATE fl_loading_plan_item SET package_count=2 WHERE id=1"); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id IN (2,4)"); + assertThat(owners.selectCompletionOwners(20)).isEqualTo(before); + } + + @Test + void allActualRowsMayBePartialAndAnEntirelySkippedTaskHasNoCompletionOwner() { + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE id=3"); + assertThat(owners.selectCompletionOwners(20)).containsExactly(new InboundOwner(101L, 50L)); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE id=1"); + assertThat(owners.selectCompletionOwners(20)).isEmpty(); + } + + @Test + void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() { + checkpoints.checkpointCompleted(900, 101, 50, 1, "READY"); + checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED"); + checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, 303, 70, 3, "READY"); + assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); + assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED"); + assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY"); + assertThat(owners.selectCompletionOwners(20)).containsExactly(new InboundOwner(101L, 50L), new InboundOwner(202L, 60L)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isEqualTo(3); + } + + private void row(long id, long tenant, long inbound, int planned, String status, int picked, long plan, int deleted) { + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(?,?,?,?,?,?)", id, plan, tenant, inbound, deleted, planned); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,20,?,?,?)", id, id, status, picked); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java index 1ae756746..87b01651f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java @@ -123,7 +123,7 @@ class LoadingCompletedPreflightDatabaseTest { var task = new com.baiyue.firstleg.entity.LoadingPickTaskEntity(); task.setId(92L); task.setLoadingPlanId(90L); task.setContainerBookingId(91L); when(repository.task(92L)).thenReturn(task); - when(repository.inboundOwners(92L)).thenReturn(List.of( + when(repository.completionOwners(92L)).thenReturn(List.of( new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(11L,1L), new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(22L,2L))); var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 448c86838..04795248e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -157,6 +157,8 @@ class LoadingPickServiceTest { when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); when(repository.inboundOwners(anyLong())).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.completionOwners(anyLong())).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); when(repository.inbound(anyLong(), anyLong())).thenAnswer(invocation -> new LoadingInboundMapper.Snapshot(invocation.getArgument(1), invocation.getArgument(0), "LOADING", 1)); when(dropshipPallets.physicalMetricsByPalletIds(any())).thenReturn(List.of( @@ -1033,6 +1035,92 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); } + @Test + void workerNeverSettlesAWholeSkippedOwnerAlongsideActualPartialAndFullCargo() { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(202L, 60L), + new LoadingPickItemMapper.InboundOwner(303L, 70L))); + when(repository.completionOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(202L, 60L))); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(930L, "worker-actual-only")).thenReturn(true); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "PDA任务不包含指定入库单")) + .when(settlementService).settleOwner(20L, 70L, 9L, "operator"); + + service.processCompletion(completionCommand(930L, "actual-only"), "worker-actual-only"); + + verify(loadingDeliveryService, never()).requirePdaCompletionReady(303L, 70L); + verify(settlementService, never()).settleOwner(20L, 70L, 9L, "operator"); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(303L), eq(70L), anyLong(), + anyString(), anyString(), anyString()); + verify(loadingListService).publishCompletedEvent(10L); + verify(completionCommands).complete(930L, "worker-actual-only"); + } + + @Test + void completedActualOwnerCheckpointsBypassReadyAndSettlementWithoutTouchingSkippedReadyCheckpoint() { + LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 60L), + new LoadingPickItemMapper.InboundOwner(303L, 70L))); + when(repository.completionOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 60L))); + when(completionCommands.checkpoint(931L, 101L, 50L)).thenReturn("INBOUND_COMPLETED"); + when(completionCommands.checkpoint(931L, 202L, 60L)).thenReturn("INBOUND_COMPLETED"); + when(completionCommands.checkpoint(931L, 303L, 70L)).thenReturn("READY"); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "实际来源已经LOADED")) + .when(loadingDeliveryService).requirePdaCompletionReady(anyLong(), anyLong()); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(931L, "worker-completed-checkpoints")).thenReturn(true); + + service.processCompletion(completionCommand(931L, "completed-checkpoints"), "worker-completed-checkpoints"); + + verify(loadingDeliveryService, never()).requirePdaCompletionReady(anyLong(), anyLong()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); + verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L)); + verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString()); + verify(loadingListService).publishCompletedEvent(10L); + verify(completionCommands).complete(931L, "worker-completed-checkpoints"); + } + + @Test + void actualLoadedOwnerWithoutCompletedCheckpointStillFailsItsExistingReadyGate() { + LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.completionOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "实际来源已经LOADED且无完成检查点")) + .when(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + + assertThatThrownBy(() -> service.processCompletion(completionCommand(932L, "missing-checkpoint"), "worker-missing-checkpoint")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage()).isEqualTo("OWNER_READY")); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + } + + @Test + void anEntirelySkippedTaskCannotCreateACompletionCommand() { + when(repository.taskForUpdate(20L)).thenReturn(task()); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.completionOwners(20L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.complete(20L, new CompleteLoadingRequest(0, "all-skipped", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("未关联有效入库单"); + verify(completionCommands, never()).insert(any()); + verify(repository, never()).acceptCompletion(anyLong(), anyInt()); + } + @Test void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() { LoadingPickTaskEntity completing = task(); @@ -1040,7 +1128,7 @@ class LoadingPickServiceTest { when(repository.task(20L)).thenReturn(completing); List owners = java.util.stream.IntStream.range(0, 15) .mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList(); - when(repository.inboundOwners(20L)).thenReturn(owners); + when(repository.completionOwners(20L)).thenReturn(owners); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.review(20L)).thenReturn(null); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); @@ -1066,7 +1154,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); task.setTaskStatus(initialTaskStatus); when(repository.task(20L)).thenReturn(task); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); @@ -1102,7 +1190,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.review(20L)).thenReturn(null); @@ -1129,7 +1217,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(202L, 80L), new LoadingPickItemMapper.InboundOwner(101L, 60L), new LoadingPickItemMapper.InboundOwner(202L, 70L), @@ -1168,7 +1256,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 70L))); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "第二客户门禁未就绪")) @@ -1196,7 +1284,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(101L, 60L))); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, @@ -1224,7 +1312,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 60L))); when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED"); -- 2.54.0 From 4bded73040e02196039cdc9806cd06ab34ecc02c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 22:11:31 +0800 Subject: [PATCH 143/176] fix(iam): register stock transfer execution permission --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 ++ .../main/resources/db/lastleg-permissions.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 33 +++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9149abd55..ceef1fc90 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6329,6 +6329,8 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107004,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107005,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 775750945..4efba3d74 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -23,6 +23,7 @@ VALUES (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0), (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,0), + (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,0), (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 19af389d5..acb7a22f0 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -14,6 +14,29 @@ import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; class OverseasPhase4PermissionSqlContractTest { + @Test + void stockTransferExecutionPermissionIsSeededAndNotGrantedByWarehouseReadOnlyRoles() throws Exception { + String module = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path root = Files.exists(Path.of("deploy/sql")) ? Path.of(".") : Path.of(".."); + String unified = Files.readString(root.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); + String controller = Files.readString(root.resolve( + "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java")); + String permission = "lastleg:warehouse-transfer:manage"; + assertThat(controller).contains("MANAGE_PERMISSION = \"" + permission + "\"", + "requirePermission(user, MANAGE_PERMISSION)"); + for (String sql : List.of(module, unified)) { + assertThat(sql).contains("(5107005,0,'PLATFORM','BUTTON',5107000,'" + permission + + "','执行仓储转运',NULL,'POST',705,'ACTIVE',1"); + } + assertThat(module).contains("permission.id BETWEEN 5107000 AND 5107299", + "role.role_code='PLATFORM_ADMIN'"); + assertThat(unified).contains("(4000001,5107005,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("(4000004,5107005,0,CURRENT_TIMESTAMP(3))", + "(4000005,5107005,0,CURRENT_TIMESTAMP(3))", + "(4000006,5107005,0,CURRENT_TIMESTAMP(3))"); + } + private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); private static final Set CUSTOM_ADMIN_PERMISSIONS = Set.of( "platform:overseas-warehouse:inbound-order:fba-appointment", @@ -41,7 +64,8 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-master-data:driver:upload", "platform:overseas-master-data:vehicle:record-create", "platform:overseas-master-data:vehicle:record-edit", - "platform:overseas-master-data:vehicle:record-upload"); + "platform:overseas-master-data:vehicle:record-upload", + "platform:overseas-master-data:vehicle:record-finance-submit"); @Test void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception { @@ -51,7 +75,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(68).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(69).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains( "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", @@ -117,7 +141,7 @@ class OverseasPhase4PermissionSqlContractTest { } @Test - void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { + void servicePermissionDeclarationsExactlyMatchTheIamCatalog() throws Exception { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") .getContentAsString(StandardCharsets.UTF_8)); codes(new ClassPathResource("db/phase5-delivery-permissions.sql") @@ -129,6 +153,9 @@ class OverseasPhase4PermissionSqlContractTest { "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); serviceCatalog.addAll(codes(repositoryFile( "warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java"))); + // Vehicle care already declares this guard directly on its finance-submit facade. + serviceCatalog.addAll(codes(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java"))); assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog); } -- 2.54.0 From 67c528c3e29c22828e24ca8b472885d84d904b60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 00:37:18 +0800 Subject: [PATCH 144/176] test(fba): add scoped transfer lifecycle and remaining-node evidence helpers --- scripts/README.md | 6 + ...emaining-nodes-acceptance-pda-operator.mjs | 66 +++++ ...ba-remaining-nodes-acceptance-transfer.mjs | 272 ++++++++++++++++++ ...maining-nodes-acceptance-transfer.test.mjs | 149 ++++++++++ scripts/fba-remaining-nodes-acceptance.mjs | 122 ++++++++ .../fba-remaining-nodes-acceptance.test.mjs | 20 ++ 6 files changed, 635 insertions(+) create mode 100644 scripts/fba-remaining-nodes-acceptance-pda-operator.mjs create mode 100644 scripts/fba-remaining-nodes-acceptance-transfer.mjs create mode 100644 scripts/fba-remaining-nodes-acceptance-transfer.test.mjs create mode 100644 scripts/fba-remaining-nodes-acceptance.mjs create mode 100644 scripts/fba-remaining-nodes-acceptance.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 7b35824b0..b9ad0727f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,11 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fba-remaining-nodes-acceptance-transfer.mjs` 是 F33 原已有库存转运的内部验收助手,导入不执行。`freezeTransfer` 使用显式 TEST 配置建立独立会话,只读原票和来源库存后退出;`prepareOriginalTransferPlan` / `prepareTransferStage` 只准备本票真实关联换标的逐阶段命令,不能预设关联号。`runPreparedTransferStage` 默认零网络,只有总控明确 TEST、原票、客户、阶段和稳定批次并注入已核身份的单请求流 transport 后才执行;照片以原字节摘要校验,客户确认使用本票 MERCHANT 身份,不调用被禁止的 PDA 独立复核批准。报告与写入 journal 在 `reports/fba-remaining-nodes-20261004/transfer/`,写前持久化意图;只有已明确成功的原键原载荷可显式重放一次,上传不可重放,失败或结果未知先回查,不能新建替代票、解冻其他业务或重新收货。调用者须逐阶段独立核对库存、其他 HOLD、追加流水和主体归属,HTTP 成功不自动标记业务通过;退出自身会话,不执行 Firstleg 写入、SQL、部署或删除。直接测试 `fba-remaining-nodes-acceptance-transfer.test.mjs`。 + +`fba-remaining-nodes-acceptance.mjs` 是 FBA 剩余节点内部证据助手,显式调用 `auditRemainingNodes`,导入不运行;读取指定原报告并按 JSON pointer/SHA-256 保留证据。`live=true` 仅使用明确 TEST 配置的独立 Auth 会话和串行 GET,结束退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告位于 `reports/fba-remaining-nodes-20261004/`,拒绝覆盖既有报告,失败保留且不自动重试。对应离线测试为 `fba-remaining-nodes-acceptance.test.mjs`,只读核验不等于原业务已完成。 + +`fba-remaining-nodes-acceptance-pda-operator.mjs` 是 F33 海外 PDA 的一次性 TEST 账号准备助手,导入不运行。显式调用 `createF33PdaOperator({env, credentialFile, reportFile})`,凭据来自已授权 TEST 环境;仅通过正式 IAM API 创建独立洛杉矶仓作业员、授予已存在的海外 PDA 角色并验证登录/仓绑定,不改角色权限或旧账号,不操作库存。随机凭据仅写入调用方指定的仓库外环境文件,报告建议保存 `reports/fba-remaining-nodes-20261004/pda-operator/`,不包含密码或 Token;已有文件、请求未知结果或首次强制改密均停止,禁止自动重造。退出自有管理员/PDA会话,账号保留供页面验收,报告给出精确停用接口,清理由总控在验收结束后执行。 + `fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用两新客户普通来货/PDA实收/客户确认,各一托一箱,再复用真实外接预订、来货、PDA实收与关联,三托统一目的站;逐单拣货、实际装载和审核后整车发车。登记USD1.00合成成本,以不可变DEPART实测托位、三票来源及业务号顺序独立核对最大余数分币、原币守恒、创建重放与唯一历史,再逐票签收、正式结案并分别释放车辆/司机。自有两票分别分摊,不能先合并自有托位后假设汇总一定为0.67;余分按现行逐业务号规则分配。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;入口要求验收函数显式返回本轮车次VERIFIED才能结案,不借另一证据对象的状态。失败保留原请求/样本、停止且不自动重跑,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 `fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 diff --git a/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs new file mode 100644 index 000000000..b6d675547 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs @@ -0,0 +1,66 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +// Explicit one-time TEST setup. No inventory operations, existing-user changes or role changes. +export async function createF33PdaOperator({ env, credentialFile, reportFile }) { + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') + if (fs.existsSync(credentialFile) || fs.existsSync(reportFile)) throw Error('EXISTING_RESULT_RECONCILE_NO_RECREATE') + const warehouseId = '193000000000200003', roleCode = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' + const report = { startedAt: new Date().toISOString(), warehouseId, roleCode, requests: [], success: false, + existingAccountsChanged: false, rolePermissionsChanged: false, businessOperationsIssued: 0 } + const save = () => { fs.mkdirSync(path.dirname(reportFile), {recursive:true}); fs.writeFileSync(reportFile, JSON.stringify(report,null,2)) } + let admin, pda + const call = async(method, route, body, token=admin?.accessToken) => { + const entry={method,path:route.split('?')[0],phase:'INTENT'};report.requests.push(entry);save() + let response + try { response=await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,'')+route,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(token?{Authorization:`Bearer ${token}`} : {}),...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}) } + catch {entry.phase='OUTCOME_UNKNOWN';save();throw Error('OUTCOME_UNKNOWN_RECONCILE_NO_RETRY')} + const value=JSON.parse(await response.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.status,code:String(value.code),requestId:response.headers.get('x-request-id'),traceId:response.headers.get('x-trace-id')});save() + if(response.status!==200||String(value.code)!=='200')throw Error(`FORMAL_API_REJECTED_${response.status}_${value.code}`) + return value.data + } + try { + admin=await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD},'') + const roles=await call('GET','/platform/v1/iam/roles') + const permissions=await call('GET','/platform/v1/iam/permissions') + const role=roles.find(r=>r.roleCode===roleCode&&r.status==='ACTIVE') + const operate=permissions.find(p=>p.permissionCode==='lastleg:pda:operate') + if(!role||!operate||!(role.permissionIds||[]).some(id=>String(id)===String(operate.id)))throw Error('EXISTING_ROLE_OPERATE_PERMISSION_REQUIRED') + let phone + for(let i=0;i<10;i++){ + const candidate='18'+String(crypto.randomInt(0,1000000000)).padStart(9,'0') + if(await call('GET',`/platform/v1/iam/tenants/administrator-phone-availability?phone=${candidate}`)){phone=candidate;break} + } + if(!phone)throw Error('NO_AVAILABLE_SYNTHETIC_PHONE') + const username='pda_f33_20261004_'+crypto.randomBytes(5).toString('hex') + const password='Pda1!'+crypto.randomBytes(18).toString('hex') + const writeCredentials = membershipId => fs.writeFileSync(credentialFile, + `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=${membershipId}\n`,{mode:0o600}) + fs.mkdirSync(path.dirname(credentialFile),{recursive:true}) + fs.writeFileSync(credentialFile,`PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=\n`,{flag:'wx',mode:0o600}) + const created=await call('POST','/platform/v1/iam/users',{username,password,phone, + displayName:'F33海外PDA独立验收-20261004',email:username+'@example.com',tenantId:'0',roleIds:[String(role.id)], + overseasWarehouseId:warehouseId,domesticWarehouseId:null,status:'ACTIVE'}) + report.subject={membershipId:String(created.membershipId),userId:String(created.userId),authUserId:String(created.authUserId), + tenantId:String(created.tenantId),roleIds:(created.roleIds||[]).map(String),overseasWarehouseId:String(created.overseasWarehouseId), + domesticWarehouseId:created.domesticWarehouseId??null,status:created.status,protectedAccount:created.protectedAccount} + writeCredentials(report.subject.membershipId);save() + if(report.subject.tenantId!=='0'||report.subject.overseasWarehouseId!==warehouseId||report.subject.status!=='ACTIVE' + ||report.subject.domesticWarehouseId!==null||report.subject.roleIds.length!==1||report.subject.roleIds[0]!==String(role.id))throw Error('CREATED_SUBJECT_SCOPE_MISMATCH') + pda=await call('POST','/auth/login',{loginId:username,password},'') + if(pda.passwordChangeRequired||pda.mustChangePassword||pda.firstLogin||!pda.accessToken)throw Error('FIRST_PASSWORD_CHANGE_OR_NORMAL_LOGIN_BLOCKED') + if(!(pda.authorities||[]).includes('lastleg:pda:operate')||!(pda.roles||[]).includes(roleCode)||(pda.roles||[]).length!==1)throw Error('NORMAL_LOGIN_ROLE_OR_PERMISSION_MISMATCH') + const profile=await call('GET','/pda/v1/last-leg/receiving-containers/warehouse-profile',undefined,pda.accessToken) + report.profile={warehouseId:String(profile.warehouseId),warehouseCode:profile.warehouseCode,warehouseName:profile.warehouseName} + if(report.profile.warehouseId!==warehouseId||report.profile.warehouseCode!=='US-LAX-01')throw Error('FORMAL_WAREHOUSE_PROFILE_MISMATCH') + report.cleanup={method:'PUT',path:`/platform/v1/iam/users/${report.subject.membershipId}/status`,body:{status:'DISABLED'},executeNow:false} + report.status='ACTIVE_FOR_COORDINATOR_UI';report.loginVerified=true;report.warehouseProfileVerified=true;report.success=true + }catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} + finally{ + if(pda?.refreshToken){try{await call('POST','/auth/logout',{refreshToken:pda.refreshToken},pda.accessToken);report.pdaSessionClosed=true}catch{report.pdaSessionClosed=false;report.success=false}}else report.pdaSessionClosed=!pda + if(admin?.refreshToken){try{await call('POST','/auth/logout',{refreshToken:admin.refreshToken},admin.accessToken);report.adminSessionClosed=true}catch{report.adminSessionClosed=false;report.success=false}}else report.adminSessionClosed=!admin + save() + } + return report +} diff --git a/scripts/fba-remaining-nodes-acceptance-transfer.mjs b/scripts/fba-remaining-nodes-acceptance-transfer.mjs new file mode 100644 index 000000000..de0178192 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-transfer.mjs @@ -0,0 +1,272 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { digest } from './fba-remaining-nodes-acceptance.mjs' +export const TRANSFER = 'WT-WH-20261002-430594' +export const CUSTOMER = '2105875471489142785' +export const BASE = `/platform/v1/last-leg/warehouse-transfers/${TRANSFER}?customerId=${CUSTOMER}` +export function relabelPrerequisite(detail, order) { + if (detail.needRelabel !== true) return { ready: true, scope: 'NOT_REQUIRED' } + const linked = Boolean(detail.relabelNo && (order.relabelApplications || []).some(app => + app.relabelNo === detail.relabelNo && String(app.lastlegOrderId) === String(detail.lastlegOrderId))) + const confirmed = linked && order.relabelStatus === 'COMPLETED' + return { ready: confirmed, scope: 'ORIGINAL_TRANSFER_RELABEL', linked, + relabelNo: detail.relabelNo || null, relabelStatus: order.relabelStatus || null, + bindingCount: detail.relabelBindings?.length || 0, + reason: confirmed ? null : 'Required relabel is not linked/completed for this original transfer; available source quantity does not waive it.' } +} +export function validateDestination(detail, warehouse, pallet, location) { + if (!warehouse || !pallet || !location) throw Error('EXPLICIT_DESTINATION_RECEIVING_RESOURCES_REQUIRED') + if (warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') throw Error('DESTINATION_MUST_BE_ACTIVE_OVERSEAS_RECEIVING_WAREHOUSE') + if (String(pallet.warehouseId) !== String(warehouse.id) || String(location.warehouseId) !== String(warehouse.id) + || String(pallet.locationId) !== String(location.id)) throw Error('DESTINATION_RESOURCE_OWNERSHIP_MISMATCH') + if (pallet.status !== 'IDLE' || String(pallet.customerId) !== '0' || pallet.fbaWarehouseCode !== detail.destinationWarehouseCode) throw Error('DESTINATION_RESOURCE_UNAVAILABLE_OR_WRONG_FBA') + return true +} +export function validateSource(detail, balance, position) { + if (detail.transferNo !== TRANSFER || detail.status !== 'APPROVED' || detail.packageCount !== 1 || detail.allocations.length) throw Error('ORIGINAL_APPROVED_UNALLOCATED_TICKET_REQUIRED') + if (!detail.sourceOrders.some(s => String(s.inboundOrderId) === String(balance.inboundOrderId))) throw Error('SOURCE_NOT_BOUND_TO_ORIGINAL_TICKET') + if (String(balance.customerId) !== CUSTOMER || String(position.customerId) !== CUSTOMER || String(position.balanceId) !== String(balance.id)) throw Error('CUSTOMER_OR_BALANCE_MISMATCH') + if (String(balance.overseasWarehouseId) !== String(detail.operatingWarehouseId) || !position.palletId || Number(position.availablePackages) < 1) throw Error('SOURCE_UNAVAILABLE') + if (Number(position.holdPackages || 0) || Number(position.frozenPackages || 0) || Number(position.allocatedPackages || 0) || Number(position.pickedPackages || 0)) throw Error('SOURCE_HAS_OTHER_OCCUPANCY') + return { inventoryPositionId: String(position.id), palletId: String(position.palletId), packageCount: 1 } +} +export async function withTransferSession({env, output}, work) { + if (fs.existsSync(output)) throw Error('REPORT_EXISTS_NO_OVERWRITE') + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') + const report = { transferNo: TRANSFER, customerId: CUSTOMER, startedAt: new Date().toISOString(), requests: [], success: false } + const save = () => { fs.mkdirSync(path.dirname(output), {recursive:true}); fs.writeFileSync(output, JSON.stringify(report,null,2)) } + let session + async function call(method, route, body) { + const auth = route.startsWith('/auth/') + if (!auth && !route.startsWith('/platform/v1/last-leg/') && !route.startsWith('/platform/v1/overseas-warehouse-resources/') && !route.startsWith('/platform/v1/warehouse-admin')) throw Error('FIRSTLEG_OR_UNSCOPED_ROUTE_FORBIDDEN') + const entry = { method, path: route, phase: 'INTENT', ...(!auth && body ? {body, bodyHash:digest(body)} : {}) } + report.requests.push(entry); save() + let response + try { response = await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,'')+route, {method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(session?{Authorization:`Bearer ${session.accessToken}`} : {}),...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}) } + catch { entry.phase='OUTCOME_UNKNOWN'; save(); throw Error('OUTCOME_UNKNOWN_RECONCILE_ONLY_NO_RETRY') } + const json=JSON.parse(await response.text()); Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.status,code:String(json.code),requestId:response.headers.get('x-request-id'),traceId:response.headers.get('x-trace-id')});save() + if(response.status!==200||String(json.code)!=='200')throw Error(`API_FAILURE_${response.status}_${json.code}`) + return json.data + } + async function pages(route) { + const records=[]; let total + for(let page=1;page<=100;page++) {const value=await call('GET',`${route}${route.includes('?')?'&':'?'}page=${page}&size=200`);if(total!==undefined&&total!==Number(value.total))throw Error('PAGINATION_TOTAL_CHANGED');total=Number(value.total);records.push(...value.records);if(records.length>=total){if(records.length!==total)throw Error('PAGINATION_COUNT_INVALID');return records}} + throw Error('PAGINATION_BOUND_EXCEEDED') + } + try {session=await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD});await work({call,pages,report,save});report.success=true} + catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} + finally { if(session){try{await call('POST','/auth/logout',{refreshToken:session.refreshToken});report.sessionClosed=true}catch{report.sessionClosed=false;report.success=false}}else report.sessionClosed=true;save() } + return report +} +export async function freezeTransfer({env,output}) { + return withTransferSession({env,output},async({call,pages,report,save})=>{ + const detail=await call('GET',BASE) + const balances=await pages(`/platform/v1/last-leg/inventory/balances?customerId=${CUSTOMER}`) + const ledger=await pages(`/platform/v1/last-leg/inventory/ledger?customerId=${CUSTOMER}`) + const sourceCandidates=[] + for(const b of balances.filter(b=>detail.items.some(i=>i.skuCode===b.skuCode)))sourceCandidates.push(await call('GET',`/platform/v1/last-leg/inventory/balances/${b.id}?customerId=${CUSTOMER}`)) + const sources=sourceCandidates.filter(b=>detail.sourceOrders.some(s=>String(s.inboundOrderId)===String(b.inboundOrderId))) + const positions=[] + for(const b of sources)positions.push(...await call('GET',`/platform/v1/last-leg/inventory/balances/${b.id}/positions?customerId=${CUSTOMER}`)) + Object.assign(report,{detail,balances,ledger,sources,positions,snapshotHash:digest({detail,balances,ledger,positions})});save() + report.scopeReady=sources.length>0&&positions.some(p=>Number(p.availablePackages)>=1) + }) +} + +export const LA = '193000000000200003' +export const NY = '193000000000200004' +const PDA_MEMBER = '2106727769572478980' +export const TRANSFER_STAGES = Object.freeze({ + approve: {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:status',replay:'SAME_STATUS_NOOP'}, + claim: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'create-work-pallet': {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + bind: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + unit: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + photo: {actor:'pda',permission:'lastleg:pda:operate',replay:'NONE'}, + complete: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'review-read': {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:list',replay:'READ_ONLY'}, + 'customer-confirm': {actor:'merchant',permission:'merchant:overseas-warehouse:relabel:confirm',replay:'KEYED'}, + outbound: {actor:'platform',permission:'lastleg:warehouse-transfer:manage',replay:'KEYED'}, + receipt: {actor:'platform',permission:'lastleg:warehouse-transfer:manage',replay:'KEYED'}, +}) +const demand = (value, message) => { if (!value) throw Error(message) } +const version = value => { demand(Number.isInteger(value)&&value>=0,'FORMAL_CURRENT_VERSION_REQUIRED');return value } +export function prepareOriginalTransferPlan(detail, runId) { + demand(/^[A-Za-z0-9_-]{1,48}$/.test(runId),'STABLE_RUN_ID_REQUIRED') + demand(detail?.transferNo===TRANSFER&&detail.needRelabel===true&&Number(detail.packageCount)===1 + &&String(detail.operatingWarehouseId)===LA&&detail.destinationWarehouseCode==='ABE4','ORIGINAL_TICKET_SCOPE_REQUIRED') + return {runId,transfer:structuredClone(detail),customerId:CUSTOMER,networkIssued:false, + linked:Boolean(detail.relabelNo&&detail.relabelApplicationId), + reconcile:{method:'GET',path:BASE}, + phases:Object.entries(TRANSFER_STAGES).map(([stage,rule])=>({stage,...rule})), + reviewBoundary:'PDA /review is intentionally rejected; platform review-task is read-only. Customer confirm-completion atomically completes review.', + receiptWarehouseId:NY,businessDestination:'ABE4',pdaMembershipId:PDA_MEMBER} +} +function ownRelabel(plan, linked) { + demand(plan.linked,'WAIT_FOR_ORIGINAL_ALLOCATION_NO_ASSUMED_RELABEL') + const app=linked?.application + demand(app&&app.relabelNo===plan.transfer.relabelNo&&String(app.id)===String(plan.transfer.relabelApplicationId) + &&String(app.lastlegOrderId)===String(plan.transfer.lastlegOrderId)&&app.customerOrderNo===TRANSFER + &&String(app.overseasWarehouseId)===LA,'EXACT_ORIGINAL_RELABEL_RELATION_REQUIRED') + return encodeURIComponent(app.relabelNo) +} +function commandHash(command) { + const {preparedHash,...value}=command + return digest(value) +} +export function prepareTransferStage(plan, stage, context={}) { + const rule=TRANSFER_STAGES[stage];demand(rule,'UNSUPPORTED_STAGE_NO_PDA_REVIEW_APPROVE') + const no=ownRelabel(plan,context.linked) + const key=`F33-WT-430594-${plan.runId}-${stage}` + const command={stage,runId:plan.runId,transferNo:TRANSFER,customerId:CUSTOMER,...rule,method:'POST',path:'',body:null} + const pdaBase=`/pda/v1/last-leg/relabel-tasks/${no}` + const platformBase=`/platform/v1/last-leg/relabel-applications/${no}` + const task=context.task + if(['claim','unit','complete'].includes(stage))demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER,'OWN_CURRENT_PDA_TASK_REQUIRED') + if(stage==='approve'){command.method='PUT';command.path=platformBase+'/status';command.body={status:'PENDING_PROCESSING',remark:key}} + if(stage==='claim'){command.path=pdaBase+'/claim';command.body={idempotencyKey:key}} + if(stage==='create-work-pallet'){command.path='/pda/v1/last-leg/pallet-operations/relabel-pallet-label';command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key}} + if(stage==='bind'){ + const {source,target,observedCartonMarks}=context + demand(source&&target&&String(source.warehouseId)===LA&&String(target.warehouseId)===LA + &&target.destinationWarehouseCode==='ABE4'&&source.palletCode!==target.palletCode,'FORMAL_LA_WORK_PALLETS_REQUIRED') + const marks=new Set((context.linked.items||[]).map(i=>i.cartonMark)) + demand(Array.isArray(observedCartonMarks)&&observedCartonMarks.length===1&&observedCartonMarks.every(m=>marks.has(m)),'OBSERVED_OWN_CARTON_REQUIRED') + demand(plan.transfer.allocations?.some(a=>String(a.inventoryPositionId)===String(source.positionId) + &&String(a.palletId)===String(source.palletId)&&Number(a.packageCount)===1),'ORIGINAL_SELECTED_POSITION_REQUIRED') + command.path='/pda/v1/last-leg/pallet-operations/relabel-disassemble' + command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key, + source:{palletCode:source.palletCode,expectedVersion:version(source.palletVersion)}, + target:{palletCode:target.palletCode,expectedVersion:version(target.palletVersion)},cartonMarks:observedCartonMarks, + actualSource:{positionId:source.positionId,expectedPositionVersion:version(source.positionVersion),packages:1},reason:key} + } + if(stage==='unit'){ + const item=task.items?.find(i=>String(i.itemId)===String(context.itemId)) + demand(item&&item.operationType==='CARTON_AND_SKU','OWN_COMBINED_ITEM_REQUIRED') + demand(context.observedOldLabel===item.cartonMark&&context.observedNewLabel===item.fbaCartonMark,'OBSERVED_LABELS_MUST_MATCH_OWN_ITEM') + demand(Array.isArray(context.observedSkus)&&digest(context.observedSkus)===digest((item.skuItems||[]).map(s=>({skuCode:s.skuCode,newSkuCode:s.newSkuCode,quantity:s.quantity}))), 'OBSERVED_SKU_QUANTITIES_REQUIRED') + command.path=pdaBase+'/units';command.body={itemId:item.itemId,oldLabel:context.observedOldLabel,newLabel:context.observedNewLabel, + expectedVersion:version(task.version),idempotencyKey:key,skuVerifications:context.observedSkus} + } + if(stage==='photo'){ + const photo=context.photo + demand(photo&&/^[a-f0-9]{64}$/.test(photo.sha256)&&Number.isSafeInteger(photo.sizeBytes)&&photo.sizeBytes>0 + &&photo.contentType?.startsWith('image/')&&photo.fileName,'EXPLICIT_PHOTO_CONTENT_REQUIRED') + command.path=pdaBase+'/photos/files';command.body=null + command.multipart={field:'file',fileName:photo.fileName,contentType:photo.contentType,sizeBytes:photo.sizeBytes,sha256:photo.sha256} + } + if(stage==='complete'){ + demand(task.taskStatus==='PROCESSING'&&Number(task.expectedPackages)===1&&Number(task.relabelledPackages)===1 + &&Number(task.skippedPackages)===0&&Number(task.exceptionPackages)===0,'FULL_NORMAL_WORK_FACTS_REQUIRED') + const ownMarks=new Set(task.items.map(i=>i.cartonMark)) + const bound=(task.history||[]).some(h=>{ + if(h.action!=='PALLET_BIND')return false + try{const proof=JSON.parse(h.detail);return proof.round===(task.reviewedAt||'INITIAL') + &&proof.cartonMarks?.some(m=>ownMarks.has(m))&&proof.targetPallets?.length>0}catch{return false} + }) + demand(bound&&Array.isArray(context.photoFileIds)&&context.photoFileIds.length>0 + &&context.photoFileIds.every(id=>/^[1-9]\d*$/.test(String(id))) + &&new Set(context.photoFileIds.map(String)).size===context.photoFileIds.length,'ACTUAL_BINDING_AND_OWN_PHOTOS_REQUIRED') + command.path=pdaBase+'/complete';command.body={photoFileIds:context.photoFileIds,expectedVersion:version(task.version),idempotencyKey:key,remark:key,manualCompletion:false} + } + if(stage==='review-read'){command.method='GET';command.path=platformBase+'/review-task';command.body=null} + if(stage==='customer-confirm'){ + demand(task?.taskStatus==='WAIT_REVIEW'&&task.relabelNo===plan.transfer.relabelNo&&Number(task.relabelledPackages)===1 + &&task.completionPhotoFileIds?.length>0,'SUBMITTED_WORK_AND_PHOTOS_REQUIRED') + command.path=`/merchant/v1/last-leg/relabel-applications/${no}/confirm-completion`;command.body={idempotencyKey:key,remark:key} + } + if(stage==='outbound'){ + demand(plan.transfer.status==='ALLOCATED'&&relabelPrerequisite(plan.transfer,context.order).ready,'OWN_RELABEL_CUSTOMER_CONFIRMATION_REQUIRED') + demand(plan.transfer.allocations?.length===1&&plan.transfer.allocations[0].status==='RESERVED' + &&plan.transfer.allocations[0].skuCode===plan.transfer.relabelBindings[0].newSku,'CURRENT_NEW_SKU_ALLOCATION_REQUIRED') + command.path=`/platform/v1/last-leg/warehouse-transfers/${TRANSFER}/outbound?customerId=${CUSTOMER}`;command.body={idempotencyKey:key,remark:key} + } + if(stage==='receipt'){ + const a=plan.transfer.allocations?.[0],t=context.receiptTarget + demand(plan.transfer.status==='OUTBOUNDED'&&plan.transfer.allocations.length===1&&a.status==='SHIPPED' + &&Number(a.packageCount)===1&&a.skuCode===plan.transfer.relabelBindings[0].newSku,'CURRENT_SHIPPED_NEW_SKU_ALLOCATION_REQUIRED') + demand(t&&String(t.warehouse?.id)===NY,'EXPLICIT_NEW_YORK_RECEIVING_TARGET_REQUIRED') + validateDestination(plan.transfer,t.warehouse,t.pallet,t.location) + command.path=`/platform/v1/last-leg/warehouse-transfers/${TRANSFER}/receipts?customerId=${CUSTOMER}` + command.body={sourceInventoryPositionId:a.inventoryPositionId,destinationOverseasWarehouseId:NY, + destinationOverseasWarehouseCode:t.warehouse.warehouseCode,destinationOverseasWarehouseName:t.warehouse.warehouseName, + destinationWarehouseId:'193000000000000003',destinationWarehouseCode:'ABE4',destinationWarehouseName:'ABE4', + destinationPalletId:t.pallet.id,destinationPalletNo:t.pallet.palletCode,warehouseLocationId:t.location.id, + warehouseLocationCode:t.location.locationCode,batchNo:TRANSFER,idempotencyKey:key,remark:key} + } + command.preparedHash=commandHash(command) + return command +} +export async function runPreparedTransferStage({command,release,actor,transport,journalDirectory,binary,replay=false}) { + if(!release?.execute)return {mode:'PLAN',networkIssued:false,command} + demand(release.environment==='TEST'&&release.transferNo===TRANSFER&&release.customerId===CUSTOMER + &&release.stage===command.stage&&release.runId===command.runId,'EXACT_STAGE_RELEASE_REQUIRED') + demand(command.preparedHash===commandHash(command),'PREPARED_COMMAND_CHANGED') + const policy=TRANSFER_STAGES[command.stage];demand(policy,'UNSUPPORTED_STAGE') + demand(command.transferNo===TRANSFER&&command.customerId===CUSTOMER + &&/^[A-Za-z0-9_-]{1,48}$/.test(command.runId),'ORIGINAL_COMMAND_SCOPE_REQUIRED') + const commandFields=new Set(['stage','runId','transferNo','customerId','actor','permission','replay','method','path','body','multipart','preparedHash']) + demand(Object.keys(command).every(field=>commandFields.has(field)),'NO_CREDENTIALS_OR_EXTRA_TRANSPORT_FIELDS_IN_JOURNAL') + demand(actor?.kind===policy.actor&&Array.isArray(actor.authorities)&&actor.authorities.includes(policy.permission),'ACTOR_PERMISSION_REQUIRED') + if(policy.actor==='pda')demand(Array.isArray(actor.roles)&&actor.roles.includes('OVERSEAS_WAREHOUSE_PDA_OPERATOR') + &&actor.appCode==='PLATFORM'&&String(actor.tenantId)==='0' + &&String(actor.membershipId)===PDA_MEMBER&&String(actor.warehouseId)===LA,'OWN_PDA_IDENTITY_REQUIRED') + else demand(actor.appCode===(policy.actor==='merchant'?'MERCHANT':'PLATFORM') + &&String(actor.tenantId)===(policy.actor==='merchant'?CUSTOMER:'0'),'ACTOR_SCOPE_REQUIRED') + demand(typeof transport==='function'&&journalDirectory,'EXPLICIT_TRANSPORT_AND_JOURNAL_REQUIRED') + if(command.multipart)demand(binary&&binary.length===command.multipart.sizeBytes + &&crypto.createHash('sha256').update(binary).digest('hex')===command.multipart.sha256,'PHOTO_BYTES_CHANGED') + fs.mkdirSync(journalDirectory,{recursive:true}) + const file=path.join(journalDirectory,`${command.runId}-${command.stage}.json`) + let journal + if(fs.existsSync(file)){ + journal=JSON.parse(fs.readFileSync(file,'utf8')) + demand(replay&&['KEYED','SAME_STATUS_NOOP','READ_ONLY'].includes(policy.replay) + &&journal.success===true&&journal.command.preparedHash===command.preparedHash + &&!journal.replay,'ONLY_ACKNOWLEDGED_EXACT_ORIGINAL_REPLAY_ALLOWED') + journal.replay={phase:'INTENT'};fs.writeFileSync(file,JSON.stringify(journal,null,2)) + }else{ + demand(!replay,'ORIGINAL_ACKNOWLEDGED_COMMAND_REQUIRED') + journal={command,actor:{kind:actor.kind,membershipId:actor.membershipId,warehouseId:actor.warehouseId},phase:'INTENT',success:false,businessVerified:false} + fs.writeFileSync(file,JSON.stringify(journal,null,2),{flag:'wx'}) + } + const entry=replay?journal.replay:journal + try{ + const response=await transport(command,binary) + Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.http,code:String(response.code),requestId:response.requestId,traceId:response.traceId,resultDigest:digest(response.data??null)}) + entry.success=response.http===200&&String(response.code)==='200' + fs.writeFileSync(file,JSON.stringify(journal,null,2)) + demand(entry.success,'STAGE_REJECTED_STOP_NO_RECREATE') + return {mode:'EXECUTED',journalFile:file,response} + }catch(error){ + if(entry.phase==='INTENT'){entry.phase='OUTCOME_UNKNOWN';entry.success=false;fs.writeFileSync(file,JSON.stringify(journal,null,2))} + throw Error(entry.phase==='OUTCOME_UNKNOWN'?'OUTCOME_UNKNOWN_READBACK_ONLY_NO_RETRY':'STAGE_REJECTED_STOP_NO_RECREATE') + } +} + +export function verifyTransferMilestone(stage, facts) { + demand(['selection','bind','unit','customer-confirm','outbound','receipt'].includes(stage),'UNKNOWN_STOCK_MILESTONE') + const count=value=>{demand((typeof value==='number'||typeof value==='string'&&/^\d+$/.test(value)) + &&Number.isSafeInteger(Number(value))&&Number(value)>=0,'EXPLICIT_INTEGER_STOCK_FACT_REQUIRED');return Number(value)} + const beforeSource=count(facts.baselineSourceTotal),beforeTarget=count(facts.baselineTargetTotal) + const source=count(facts.sourceTotal),target=count(facts.targetTotal) + const departed=['outbound','receipt'].includes(stage),received=stage==='receipt' + demand(source===beforeSource-(departed?1:0)&&target===beforeTarget+(received?1:0),'ORIGINAL_ONE_CARTON_STOCK_DELTA_REQUIRED') + demand(facts.protectedBefore&&facts.protectedAfter&&digest(facts.protectedBefore)===digest(facts.protectedAfter),'OTHER_HOLD_AND_STOCK_MUST_REMAIN_IDENTICAL') + const past=new Map((facts.ledgerAfter||[]).map(row=>[String(row.id),row])) + demand(Array.isArray(facts.ledgerBefore)&&facts.ledgerBefore.every(row=>past.has(String(row.id))&&digest(past.get(String(row.id)))===digest(row)), 'PRIOR_LEDGER_FACTS_MUST_NOT_CHANGE') + demand(past.size===(facts.ledgerAfter||[]).length,'DUPLICATE_LEDGER_IDS_NOT_COMPLETE_EVIDENCE') + if(departed){ + const ship=facts.ledgerAfter.filter(row=>row.businessType==='SHIP'&&row.businessOrderNo===TRANSFER) + demand(facts.expectedNewSku&&ship.length===1&&Number(ship[0].deltaPackages)===-1 + &&ship[0].skuCode===facts.expectedNewSku,'UNIQUE_ORIGINAL_NEW_SKU_SHIPMENT_REQUIRED') + } + if(received){ + const receipts=facts.ledgerAfter.filter(row=>row.businessType==='STOCK_IN'&&row.businessOrderNo===TRANSFER + &&String(row.overseasWarehouseId)===NY) + demand(receipts.length===1&&Number(receipts[0].deltaPackages)===1 + &&receipts[0].skuCode===facts.expectedNewSku,'UNIQUE_NEW_YORK_NEW_SKU_RECEIPT_REQUIRED') + } + return {verified:true,stage,sourceTotal:source,targetTotal:target,inTransitCartons:departed&&!received?1:0} +} diff --git a/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs b/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs new file mode 100644 index 000000000..789a259df --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs @@ -0,0 +1,149 @@ +import {test} from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import crypto from 'node:crypto' +import {validateSource,validateDestination,relabelPrerequisite,TRANSFER,CUSTOMER} from './fba-remaining-nodes-acceptance-transfer.mjs' +import {LA,NY,prepareOriginalTransferPlan,prepareTransferStage,runPreparedTransferStage,verifyTransferMilestone} from './fba-remaining-nodes-acceptance-transfer.mjs' +const detail={transferNo:TRANSFER,status:'APPROVED',packageCount:1,allocations:[],operatingWarehouseId:'w',sourceOrders:[{inboundOrderId:'o'}]} +const balance={id:'b',customerId:CUSTOMER,inboundOrderId:'o',overseasWarehouseId:'w'} +const position={id:'p',balanceId:'b',customerId:CUSTOMER,palletId:'t',availablePackages:1,frozenPackages:0,pickedPackages:0} +test('original approved one-carton source passes exact ownership',()=>assert.deepEqual(validateSource(detail,balance,position),{inventoryPositionId:'p',palletId:'t',packageCount:1})) +test('other source, customer, warehouse and unavailable or occupied cartons stop before commands',()=>{ + for(const b of [{...balance,inboundOrderId:'other'},{...balance,customerId:'other'},{...balance,overseasWarehouseId:'other'}])assert.throws(()=>validateSource(detail,b,position)) + for(const p of [{...position,availablePackages:0},{...position,frozenPackages:1},{...position,holdPackages:1},{...position,allocatedPackages:1},{...position,pickedPackages:1},{...position,palletId:null}])assert.throws(()=>validateSource(detail,balance,p)) + assert.throws(()=>validateSource({...detail,allocations:[{}]},balance,position)) +}) +test('missing destination or FBA master cannot silently become a receiving overseas resource',()=>{ + assert.throws(()=>validateDestination(detail)) + assert.throws(()=>validateDestination(detail,{warehouseType:'FBA',status:'ACTIVE'},{},{})) + const d={destinationWarehouseCode:'ABE4'},w={id:'ny',warehouseType:'OVERSEAS',status:'ACTIVE'} + const p={warehouseId:'ny',locationId:'loc',status:'IDLE',customerId:'0',fbaWarehouseCode:'ABE4'} + const l={id:'loc',warehouseId:'ny'} + assert.equal(validateDestination(d,w,p,l),true) + assert.throws(()=>validateDestination(d,w,{...p,warehouseId:'la'},l)) + assert.throws(()=>validateDestination(d,w,{...p,status:'OCCUPIED'},l)) +}) +test('available original-source cartons cannot waive original ticket relabel binding',()=>{ + const d={needRelabel:true,lastlegOrderId:'13',relabelBindings:[{quantity:1}]} + assert.equal(relabelPrerequisite(d,{relabelStatus:'PENDING_BINDING'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'other',lastlegOrderId:'13'}],relabelStatus:'COMPLETED'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'13'}],relabelStatus:'PENDING_PROCESSING'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'other'}],relabelStatus:'COMPLETED'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'13'}],relabelStatus:'COMPLETED'}).ready,true) +}) + +function linkedFixture(status='WAIT_RELABEL'){ + const transfer={transferNo:TRANSFER,needRelabel:true,packageCount:1,operatingWarehouseId:LA,destinationWarehouseCode:'ABE4', + relabelNo:'RL-SYNTHETIC-ONLY',relabelApplicationId:'app',lastlegOrderId:'tail',status, + relabelBindings:[{newSku:'NEW'}],allocations:[{inventoryPositionId:'selected-old',palletId:'source-pallet',packageCount:1,status:'RESERVED',skuCode:'OLD'}]} + const linked={application:{id:'app',relabelNo:transfer.relabelNo,lastlegOrderId:'tail',customerOrderNo:TRANSFER,overseasWarehouseId:LA},items:[{cartonMark:'BOX-1'}]} + const task={relabelNo:transfer.relabelNo,customerOrderNo:TRANSFER,taskStatus:'PROCESSING',version:2, + expectedPackages:1,relabelledPackages:1,skippedPackages:0,exceptionPackages:0, + items:[{itemId:'item',cartonMark:'BOX-1',fbaCartonMark:'FBA-BOX-1',operationType:'CARTON_AND_SKU',skuItems:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]}], + history:[{action:'PALLET_BIND',detail:JSON.stringify({round:'INITIAL',cartonMarks:['BOX-1'],targetPallets:['WORK-1']})}]} + return {transfer,linked,task,plan:prepareOriginalTransferPlan(transfer,'offline-contract')} +} +const platform={kind:'platform',appCode:'PLATFORM',tenantId:'0',authorities:['platform:overseas-warehouse:relabel-order:status']} +const release=stage=>({execute:true,environment:'TEST',transferNo:TRANSFER,customerId:CUSTOMER,stage,runId:'offline-contract'}) +function temporaryJournal(){const directory=fs.mkdtempSync(path.join(os.tmpdir(),'f33-stage-'));return {directory,cleanup(){for(const file of fs.readdirSync(directory))fs.unlinkSync(path.join(directory,file));fs.rmdirSync(directory)}}} +test('unallocated original ticket remains a zero-network plan without inventing a relabel number',async()=>{ + const f=linkedFixture();delete f.transfer.relabelNo;delete f.transfer.relabelApplicationId + const plan=prepareOriginalTransferPlan(f.transfer,'unallocated') + assert.equal(plan.linked,false);assert.equal(plan.networkIssued,false) + assert.throws(()=>prepareTransferStage(plan,'approve',{linked:f.linked}),/WAIT_FOR_ORIGINAL_ALLOCATION/) + assert.throws(()=>prepareTransferStage(f.plan,'review-approve',{linked:f.linked}),/UNSUPPORTED_STAGE/) + const command=prepareTransferStage(f.plan,'approve',{linked:f.linked});let calls=0 + assert.equal((await runPreparedTransferStage({command,transport:async()=>{calls++}})).mode,'PLAN');assert.equal(calls,0) +}) +test('different relabel parent IDs or customer-order linkage cannot enter the original pipeline',()=>{ + const f=linkedFixture() + for(const patch of [{id:'other'},{lastlegOrderId:'other'},{customerOrderNo:'OTHER-TICKET'},{overseasWarehouseId:NY}]){ + assert.throws(()=>prepareTransferStage(f.plan,'approve',{linked:{...f.linked,application:{...f.linked.application,...patch}}}),/EXACT_ORIGINAL_RELABEL/) + } +}) +test('actual labels and approved SKU piece counts are required for one combined unit fact',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1',observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]} + const command=prepareTransferStage(f.plan,'unit',context);assert.equal(command.body.expectedVersion,2);assert.match(command.path,/\/units$/) + assert.throws(()=>prepareTransferStage(f.plan,'unit',{...context,observedOldLabel:'OTHER'})) + assert.throws(()=>prepareTransferStage(f.plan,'unit',{...context,observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:2}]})) +}) +test('normal completion requires current-round physical binding photos and no skipped cartons',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,photoFileIds:['77']} + assert.equal(prepareTransferStage(f.plan,'complete',context).body.manualCompletion,false) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,task:{...f.task,reviewedAt:'NEW-ROUND'}})) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,task:{...f.task,skippedPackages:1}})) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,photoFileIds:[]})) +}) +test('receipt consumes the current shipped output position and keeps New York separate from ABE4',()=>{ + const f=linkedFixture('OUTBOUNDED');f.transfer.allocations=[{inventoryPositionId:'new-output',palletId:'work-pallet',packageCount:1,status:'SHIPPED',skuCode:'NEW'}] + const plan=prepareOriginalTransferPlan(f.transfer,'receipt') + const receiptTarget={warehouse:{id:NY,warehouseType:'OVERSEAS',status:'ACTIVE',warehouseCode:'US-NYC-01',warehouseName:'纽约仓'}, + pallet:{id:'ny-pallet',warehouseId:NY,locationId:'ny-location',palletCode:'F33-NY',status:'IDLE',customerId:'0',fbaWarehouseCode:'ABE4'},location:{id:'ny-location',warehouseId:NY,locationCode:'F33-NY-LOC'}} + const command=prepareTransferStage(plan,'receipt',{linked:f.linked,receiptTarget}) + assert.equal(command.body.sourceInventoryPositionId,'new-output');assert.equal(command.body.destinationOverseasWarehouseId,NY) + assert.equal(command.body.destinationWarehouseCode,'ABE4');assert.equal(command.body.destinationWarehouseId,'193000000000000003') + assert.throws(()=>prepareTransferStage(plan,'receipt',{linked:f.linked,receiptTarget:{...receiptTarget,warehouse:{...receiptTarget.warehouse,id:LA}}})) +}) +test('write intent is durable before a single transport call; only known exact original replay is allowed',async()=>{ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked}),temp=temporaryJournal();let count=0 + try{ + const transport=async c=>{count++;const j=JSON.parse(fs.readFileSync(path.join(temp.directory,`${c.runId}-${c.stage}.json`)));assert.equal(count===1?j.phase:j.replay.phase,'INTENT');return {http:200,code:'200',data:{approved:true}}} + const options={command,release:release('approve'),actor:platform,transport,journalDirectory:temp.directory} + await runPreparedTransferStage(options);await assert.rejects(runPreparedTransferStage(options));await runPreparedTransferStage({...options,replay:true}) + assert.equal(count,2);const journal=JSON.parse(fs.readFileSync(path.join(temp.directory,`${command.runId}-${command.stage}.json`)));assert.equal(journal.businessVerified,false) + await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(count,2) + }finally{temp.cleanup()} +}) +test('unknown result or explicit rejection preserves evidence and blocks retry even with the original key',async()=>{ + for(const unknown of [true,false]){ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked}),temp=temporaryJournal();let calls=0 + try{ + const transport=async()=>{calls++;if(unknown)throw Error('secret diagnostics must not be recorded');return {http:409,code:'A0409',requestId:'req',traceId:'trace'}} + const options={command,release:release('approve'),actor:platform,transport,journalDirectory:temp.directory} + await assert.rejects(runPreparedTransferStage(options));await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,1) + const raw=fs.readFileSync(path.join(temp.directory,`${command.runId}-${command.stage}.json`),'utf8');assert.doesNotMatch(raw,/secret diagnostics/) + const j=JSON.parse(raw);assert.equal(j.phase,unknown?'OUTCOME_UNKNOWN':'ACKNOWLEDGED');if(!unknown)assert.equal(j.requestId,'req') + }finally{temp.cleanup()} + } +}) +test('stage release actor or prepared-payload mismatch cannot send a request',async()=>{ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked});let calls=0 + const options={command,release:release('approve'),actor:platform,journalDirectory:'not-created',transport:async()=>{calls++}} + await assert.rejects(runPreparedTransferStage({...options,release:release('receipt')})) + await assert.rejects(runPreparedTransferStage({...options,actor:{...platform,tenantId:CUSTOMER}})) + await assert.rejects(runPreparedTransferStage({...options,command:{...command,body:{status:'REJECTED'}}})) + assert.equal(calls,0) +}) +test('photo upload bytes are frozen and an acknowledged no-key upload cannot be repeated',async()=>{ + const f=linkedFixture(),binary=Buffer.from('synthetic offline transport fixture') + const command=prepareTransferStage(f.plan,'photo',{linked:f.linked,photo:{fileName:'fixture.png',contentType:'image/png',sizeBytes:binary.length,sha256:crypto.createHash('sha256').update(binary).digest('hex')}}) + const actor={kind:'pda',appCode:'PLATFORM',tenantId:'0',membershipId:'2106727769572478980',warehouseId:LA,roles:['OVERSEAS_WAREHOUSE_PDA_OPERATOR'],authorities:['lastleg:pda:operate']} + const temp=temporaryJournal();let calls=0 + try{ + const options={command,release:release('photo'),actor,binary,journalDirectory:temp.directory,transport:async()=>{calls++;return {http:200,code:'200',data:{fileId:'77'}}}} + await assert.rejects(runPreparedTransferStage({...options,binary:Buffer.from('changed')}));assert.equal(calls,0) + await runPreparedTransferStage(options);await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,1) + }finally{temp.cleanup()} +}) +test('physical binding uses selected source and current versions without client customer/warehouse scope in PDA body',()=>{ + const f=linkedFixture(),context={linked:f.linked,source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:3,positionVersion:4},target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']} + const c=prepareTransferStage(f.plan,'bind',context) + assert.equal(c.body.actualSource.positionId,'selected-old');assert.equal(c.body.actualSource.packages,1);assert.equal(c.body.target.expectedVersion,0) + assert.equal(c.body.customerId,undefined);assert.equal(c.body.warehouseId,undefined) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,target:{...context.target,warehouseId:NY}})) +}) +test('one original carton stays conserved and existing HOLD and append-only facts remain untouched',()=>{ + const unchanged={positionId:'protected',availablePackages:0,holdPackages:1} + const ship={id:'ship',businessType:'SHIP',businessOrderNo:TRANSFER,deltaPackages:-1,skuCode:'NEW'} + const receipt={id:'receipt',businessType:'STOCK_IN',businessOrderNo:TRANSFER,overseasWarehouseId:NY,deltaPackages:1,skuCode:'NEW'} + const base={baselineSourceTotal:4,baselineTargetTotal:0,expectedNewSku:'NEW',protectedBefore:unchanged,protectedAfter:unchanged,ledgerBefore:[{id:'old',packages:1}],ledgerAfter:[ship,receipt,{id:'old',packages:1}]} + assert.equal(verifyTransferMilestone('customer-confirm',{...base,sourceTotal:4,targetTotal:0}).verified,true) + assert.equal(verifyTransferMilestone('outbound',{...base,sourceTotal:3,targetTotal:0}).inTransitCartons,1) + assert.equal(verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1}).inTransitCartons,0) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:2,targetTotal:1})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,protectedAfter:{...unchanged,holdPackages:0}})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[{id:'old',packages:2}]})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[ship,{...receipt,skuCode:'OLD'},{id:'old',packages:1}]})) +}) diff --git a/scripts/fba-remaining-nodes-acceptance.mjs b/scripts/fba-remaining-nodes-acceptance.mjs new file mode 100644 index 000000000..083b26dde --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance.mjs @@ -0,0 +1,122 @@ +// Internal evidence helper; invoked explicitly by the assigned audit, never runs on import. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +export const digest = value => crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex') +export function selectSteps(report, predicate) { + return (report.steps || []).flatMap((step, index) => predicate(step) ? [{ pointer: `/steps/${index}`, ...step }] : []) +} +export function requirePassed(steps) { + return steps.length > 0 && steps.every(s => s.passed === true) +} +export function transferConclusion(detail) { + const shipped = Boolean(detail.actualShipAt) + const terminal = detail.status === 'RECEIVED' + const received = (detail.allocations || []).length > 0 && detail.allocations.every(a => a.status === 'RECEIVED' && a.destinationInventoryPositionId) + return { status: detail.status, orderStatus: detail.orderStatus, transferNo: detail.transferNo, + allocationCount: detail.allocations?.length || 0, packageCount: detail.packageCount, + sourceCount: detail.sourceOrders?.length || 0, actualShipAt: detail.actualShipAt || null, + completed: terminal && shipped && received, requiresRealOperation: !(terminal && shipped && received) } +} +export async function auditRemainingNodes({ root, output, env, live = false }) { + if (fs.existsSync(output)) throw Error('REPORT_EXISTS_NO_OVERWRITE') + const read = relative => JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8')) + const files = ['reports/node-completion-audit-root-20261004-r1.json', + 'reports/admin-full-current-20261002-r4.json', 'reports/overseas-receiving-current-20261002-r1.json', + 'reports/fba-core-f40-tax-fleet-20261003-r1/root_first.json', + 'reports/f26-loaded-transit-verification-20261004-r2.json', + 'reports/discard-review-entry-root-20261004-r1/final-evidence.json'] + const reports = Object.fromEntries(files.map(f => [f, read(f)])) + const full = reports[files[1]] + const evidence = (file, predicate) => ({ file, sha256: digest(fs.readFileSync(path.join(root, file), 'utf8')), + steps: selectSteps(reports[file], predicate) }) + const result = { checkedAt: new Date().toISOString(), goalComplete: false, + scope: 'Existing TEST evidence only; no business commands, SQL, fault injection, deployment or UI operation', + sourceFiles: files.map(file => ({ file, sha256: digest(fs.readFileSync(path.join(root, file), 'utf8')) })), + integrationRequired: { file: 'scripts/README.md', owner: '总控', + reason: 'Current write boundary excludes shared script index; documentation contract identifies this new helper as undocumented.', + text: '`scripts/fba-remaining-nodes-acceptance.mjs`:本轮FBA剩余节点内部证据助手,仅显式调用auditRemainingNodes;读取指定原报告并按JSON pointer/SHA-256保留证据。live=true仅使用明确TEST环境文件的独立Auth会话与串行GET,正常退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告reports/fba-remaining-nodes-20261004/,既有报告拒绝覆盖,失败保留且不自动重试。对应离线测试fba-remaining-nodes-acceptance.test.mjs。' }, + nodes: [], requests: [], sessionClosed: !live } + result.nodes.push({ node: 'F23.2', conclusion: 'HTTP actual AT_ORIGIN_PORT supported; Admin save and two-end projection remain unverified', + category: '需真实操作', evidence: [evidence(files[3], s => /AT_ORIGIN_PORT/.test(s.name))], + next: 'Use a current not-yet-at-origin-port sample; freeze query version/history and customer projection, record actual node once through Admin, capture POST and both readbacks; preserve customs/container identity and do not rewrite a later actual node.' }) + result.nodes.push({ node: 'F25', conclusion: 'Clearance/DO upload and replay, fleet/appointment/empty-return supported; PU, distinct actual pickup and mail lifecycle unverified', + category: '需真实操作', evidence: [evidence(files[1], s => s.module === 'phase5-terminal-drayage'), + evidence(files[1], s => s.module === 'phase3-container-documents' && /DELIVERY_ORDER|DESTINATION_CLEARANCE/.test(s.name))], + boundaries: { mailRequestsInOriginalReport: full.steps.filter(s => /terminal-drayages.*\/emails?|terminal-drayages.*pickup-email/.test(s.request?.path || '')).length, + pickupAssertion: 'Original drayage assertion compares actualPickupAt to unloadingCompletedAt; this does not establish a distinct pickup command/time.' }, + next: 'Freeze pre-pickup container and actual node history; inspect current DO/PU contract, confirm distinct PICKED_UP before unloading; capture explicit email queue/recipient snapshot, SMTP acceptance separately, fleet-change ordered cancellation/request and failed-mail retry without assuming delivery.' }) + result.nodes.push({ node: 'F27', conclusion: 'Normal conversion/resources/replay supported; resource-failure retry UI unverified', + category: '需真实操作', evidence: [evidence(files[1], s => s.module === 'phase4-tail-order-controls')], + explicitRetryEvidence: selectSteps(full, s => /资源.*重试|计划.*重试|重试.*资源/.test(s.name)), + next: 'First locate a real resourcePendingPlanCount>0 container; freeze plans, reservations, resource identities and original command key. Use original container conversion UI retry once, verify only failed resources finish and no new plans/reservations; healthy completed samples cannot prove recovery. Failure injection requires controller window.' }) + result.nodes.push({ node: 'F33-transfer', conclusion: 'Original stock transfer approved; outbound/target receipt terminal not established', + category: '需真实操作', originalTransferNo: 'WT-WH-20261002-430594', + evidence: [evidence(files[1], s => (s.request?.path || '').includes('WT-WH-20261002-430594'))], + originalFixture: full.createdResources.phase4OverseasClientServices, + next: 'Continue this exact original transfer only after current source stock, holds, destination and allocations are frozen. Verify allocation, actual outbound and target receipt with per-position balances/unique movements and same-command replay. Incoming transfer fixtures are separate sources and cannot replace this ticket.' }) + result.nodes.push({ node: 'F33-discard', conclusion: 'Rejection release supported; approval/decrement decision pending', + category: '缺用户输入', evidenceFiles: [files[5]], next: 'Await approval-time versus PDA actual-quantity decrement decision; no approval or decrement issued.' }) + result.nodes.push({ node: 'F26', conclusion: 'Existing 68-check source/transit scope is already evidenced; no repeat run', + category: '可立即关闭指定分支', evidenceFiles: [files[4]], boundary: 'Only 11 FBA sources/13 actual cartons and pre-receipt mapping; not all event faults or UI.' }) + result.nodes.push({ node: 'F24-external', conclusion: 'External carrier lifecycle lacks valid input', + category: '缺用户输入', next: 'Provide valid carrier TEST input; synthetic fixture or manual nodes cannot close supplier lifecycle.' }) + const save = () => { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(result, null, 2)) } + save() + if (!live) return result + let session + const call = async (method, route, body, token = session?.accessToken) => { + if (method !== 'GET' && !['/auth/login', '/auth/logout'].includes(route)) throw Error('BUSINESS_WRITE_FORBIDDEN') + const entry = { method, path: route, phase: 'INTENT' }; result.requests.push(entry); save() + const response = await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/, '') + route, { + method, redirect: 'error', signal: AbortSignal.timeout(20000), + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) + const json = JSON.parse(await response.text()) + Object.assign(entry, { phase: 'ACKNOWLEDGED', http: response.status, code: String(json.code), + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }); save() + if (response.status !== 200 || String(json.code) !== '200') throw Error(`READ_FAILED_${response.status}_${json.code}`) + return json.data + } + try { + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST' || !env.CHONGCHONG_ADMIN_API_BASE_URL || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('EXPLICIT_TEST_ENV_REQUIRED') + session = await call('POST', '/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + const transferRoute = '/platform/v1/last-leg/warehouse-transfers/WT-WH-20261002-430594?customerId=2105875471489142785' + const before = await call('GET', transferRoute) + if (before.transferNo !== 'WT-WH-20261002-430594') throw Error('TRANSFER_SCOPE_MISMATCH') + result.nodes.find(n => n.node === 'F33-transfer').current = transferConclusion(before) + const container = full.createdResources.phase5TerminalDrayage.containerBookingId + const drayage = await call('GET', `/platform/v1/terminal-drayages/${container}`) + const history = await call('GET', `/platform/v1/terminal-drayages/${container}/history`) + const mailbox = await call('GET', `/platform/v1/terminal-drayages/${container}/emails`) + result.nodes.find(n => n.node === 'F25').current = { + containerNo: drayage.containerNo, status: drayage.status, actualPickupAt: drayage.actualPickupAt, + unloadingCompletedAt: drayage.unloadingCompletedAt, actualEmptyReturnAt: drayage.actualEmptyReturnAt, + pickupEqualsUnloading: drayage.actualPickupAt === drayage.unloadingCompletedAt, + historyActions: history.map(h => h.action), mailboxCount: mailbox.records?.length || 0, + mailStatuses: (mailbox.records || []).map(m => ({ status: m.status, kind: m.kind })) } + const overview = await call('GET', `/platform/v1/containers/overseas/${container}/overview`) + Object.assign(result.nodes.find(n => n.node === 'F25').current, { + pickupCodePresent: Boolean(overview.container?.pickupCode), + documents: (overview.documents || []).map(d => ({ documentType: d.documentType, version: d.version, + filePresent: Boolean(d.fileId), completed: d.completed === true })) }) + const preview = await call('GET', `/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${container}`) + result.nodes.find(n => n.node === 'F27').current = { containerNo: preview.containerNo, + totalOrderCount: preview.totalOrderCount, pendingOrderCount: preview.pendingOrderCount, + blockedOrderCount: preview.blockedOrderCount, totalPlannedPalletCount: preview.totalPlannedPalletCount, + plans: (preview.groups || []).flatMap(g => (g.orders || []).map(o => ({ planNo: o.existingPlanNo, planStatus: o.planStatus }))), + recoveryProven: false } + const after = await call('GET', transferRoute) + result.transferReadStable = digest(before) === digest(after) + result.liveReadSuccess = result.transferReadStable + } catch (error) { result.liveReadSuccess = false; result.failure = String(error.message).replace(/https?:\/\/\S+/g, '[redacted]') } + finally { + if (!session) result.sessionClosed = true + if (session) { try { await call('POST', '/auth/logout', { refreshToken: session.refreshToken }); result.sessionClosed = true } + catch { result.sessionClosed = false; result.liveReadSuccess = false } } + save() + } + return result +} diff --git a/scripts/fba-remaining-nodes-acceptance.test.mjs b/scripts/fba-remaining-nodes-acceptance.test.mjs new file mode 100644 index 000000000..4fbffa3c1 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance.test.mjs @@ -0,0 +1,20 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { requirePassed, selectSteps, transferConclusion } from './fba-remaining-nodes-acceptance.mjs' + +test('missing and partially failed evidence cannot close a branch', () => { + assert.equal(requirePassed([]), false) + assert.equal(requirePassed([{ passed: true }, { passed: false }]), false) + assert.equal(requirePassed([{ passed: true }, {}]), false) + assert.equal(requirePassed([{ passed: true }]), true) +}) +test('approved stock transfer and a signed unrelated order do not prove transfer receipt', () => { + assert.equal(transferConclusion({ status: 'APPROVED', orderStatus: 'DELIVERED' }).completed, false) + assert.equal(transferConclusion({ status: 'RECEIVED' }).completed, false) + assert.equal(transferConclusion({ status: 'OUTBOUNDED', actualShipAt: '2026-10-04' }).completed, false) + assert.equal(transferConclusion({ status: 'RECEIVED', actualShipAt: '2026-10-04', allocations: [{ status: 'RECEIVED', destinationInventoryPositionId: '42' }] }).completed, true) +}) +test('selection keeps exact original JSON pointer and failure evidence', () => { + const rows = selectSteps({ steps: [{ name: 'other', passed: true }, { name: 'original', passed: false }] }, s => s.name === 'original') + assert.deepEqual(rows, [{ pointer: '/steps/1', name: 'original', passed: false }]) +}) -- 2.54.0 From 5ad3fec449998a485ed61050e2611923f5bec6a2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 00:44:28 +0800 Subject: [PATCH 145/176] test(firstleg): preserve partial and skipped loading lifecycle regression --- scripts/README.md | 2 + .../partial-loading-actual-acceptance-plan.md | 20 +- scripts/partial-loading-actual-test-env.mjs | 975 ++++++++++++++++++ .../partial-loading-actual-test-env.test.mjs | 349 +++++++ 4 files changed, 1345 insertions(+), 1 deletion(-) create mode 100644 scripts/partial-loading-actual-test-env.mjs create mode 100644 scripts/partial-loading-actual-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index b9ad0727f..5de33449a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜的 TEST 专项入口,默认无参数或 `--plan` 零网络。使用显式授权 TEST 环境文件,匹配 Firstleg/触发器就绪并开放独占样本窗口后,才可传 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,或用 `--fixture=` 提供本轮已核对来源。新准备只走客户资格、下单、测量、装柜正式接口;A 覆盖混合客户/税制、短拣和跳过,B 的全部实装行均为 PARTIAL、退税来源全部跳过。检查实际数量/指标、报关范围及同命令重放;合成图片只证明 HTTP,不是现场证据。报告保存至 `reports/partial-loading-actual-PARTIAL-ACTUAL-<批次>/`,先记录写入意图,拒绝覆盖;失败保留原样本和报告,未知结果不得自动重造或重试。仅在明确续跑授权和原快照核对后接续,退出自己的会话;不执行 SQL/部署、船节点或历史回填,不代替事件 ACK/下游库存、完整报关文件和页面/设备验收。直接测试 `partial-loading-actual-test-env.test.mjs`。 + `fba-remaining-nodes-acceptance-transfer.mjs` 是 F33 原已有库存转运的内部验收助手,导入不执行。`freezeTransfer` 使用显式 TEST 配置建立独立会话,只读原票和来源库存后退出;`prepareOriginalTransferPlan` / `prepareTransferStage` 只准备本票真实关联换标的逐阶段命令,不能预设关联号。`runPreparedTransferStage` 默认零网络,只有总控明确 TEST、原票、客户、阶段和稳定批次并注入已核身份的单请求流 transport 后才执行;照片以原字节摘要校验,客户确认使用本票 MERCHANT 身份,不调用被禁止的 PDA 独立复核批准。报告与写入 journal 在 `reports/fba-remaining-nodes-20261004/transfer/`,写前持久化意图;只有已明确成功的原键原载荷可显式重放一次,上传不可重放,失败或结果未知先回查,不能新建替代票、解冻其他业务或重新收货。调用者须逐阶段独立核对库存、其他 HOLD、追加流水和主体归属,HTTP 成功不自动标记业务通过;退出自身会话,不执行 Firstleg 写入、SQL、部署或删除。直接测试 `fba-remaining-nodes-acceptance-transfer.test.mjs`。 `fba-remaining-nodes-acceptance.mjs` 是 FBA 剩余节点内部证据助手,显式调用 `auditRemainingNodes`,导入不运行;读取指定原报告并按 JSON pointer/SHA-256 保留证据。`live=true` 仅使用明确 TEST 配置的独立 Auth 会话和串行 GET,结束退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告位于 `reports/fba-remaining-nodes-20261004/`,拒绝覆盖既有报告,失败保留且不自动重试。对应离线测试为 `fba-remaining-nodes-acceptance.test.mjs`,只读核验不等于原业务已完成。 diff --git a/scripts/partial-loading-actual-acceptance-plan.md b/scripts/partial-loading-actual-acceptance-plan.md index 69a14da18..9d208c72a 100644 --- a/scripts/partial-loading-actual-acceptance-plan.md +++ b/scripts/partial-loading-actual-acceptance-plan.md @@ -13,7 +13,7 @@ | 柜 | 来源与操作 | 验证目的 | |---|---|---| | A | 客户甲退税普通 PACKAGE 行计划 3 箱、每箱 7 件,实拣 2 箱并提交 PARTIAL;甲另一 SKU 零拣 SKIPPED;客户乙买单普通行全拣;客户丙退税整票零拣 SKIPPED | 混合客户/税制、部分数量缩放、同票跳过 SKU 与整客户跳过隔离 | -| B | 只有买单行实际装柜;同计划退税票全部 SKIPPED | 实际全买单柜不因计划历史退税票要求客户资料或退税发送 | +| B | 唯一实际买单 PACKAGE 行计划 2 箱、实拣 1 箱并提交 PARTIAL;同计划退税票全部 SKIPPED | 全部实际行均 PARTIAL 的完成预检,以及实际全买单柜不因计划历史退税票要求客户资料或退税发送 | 从预装详情冻结每行 itemId、客户/来源/明细三键、原箱数、unitsPerCarton、productQuantity、palletCount、重量和体积。件数先按每箱件数×实箱;无每箱件数时按原量比例 HALF_UP 至整数。重量 HALF_UP 至 3 位、体积至 4 位;NULL 保持 NULL。托数按原计划托数比例向上取整。托数为空/0 的标签回退另核对原入仓总箱数、合法确认称重/库位证据,不能用本次实拣总数替换原比例分母。样本中的未知值只来自正式接口允许的输入,不以 SQL 修改事实制造样本。 @@ -48,3 +48,21 @@ 跨客户第二来源缺客户/目的仓快照、非法 PARTIAL、未结算最终发布等服务内非法事实,使用 `LoadingCompletedPreflightDatabaseTest` 的真实 Mapper 场景核验;不得通过 TEST SQL 捏造非法业务数据。有效新样本的跨客户正常完成必须走上述真实 HTTP。H2 对 MySQL UPDATE JOIN 的语法适配与 pendingAutoDispatch 关联替换均需在报告中披露;真实 MySQL 触发器与最终完成另验。 证据建议落在 `reports/partial-loading-actual-<批次>/`:版本/健康与触发器就绪证明、请求序列、before/after 全量快照、逐断言结果、事件/接收端只读取证、失败原件、会话退出结果。执行结束退出自己的全部会话,不删除数据或恢复他人资源。HTTP/DOM证据与人工1920×1080、100%缩放及PDA设备验收分别报告,不据服务测试宣布页面或全流程完成。 + +## 最小可执行入口及交给总控的 README 登记文本 + +下列段落应由总控集成到 `scripts/README.md`;本任务不修改共享索引。 + +> `partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜 TEST 专项入口,默认无参数或 `--plan` 零网络。执行必须在测试后端工作区、使用脚本前的 `node --env-file=<用户指定目录的test-acceptance.env>`,并传入 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,仅在总控确认匹配 Firstleg 与受控触发器、开放新样本窗口后运行。NEW 模式复用既有资格/订单/测量合法模块,仅准备五个隔离客户/订单与两柜;A 为退税 3→2 PARTIAL、同来源 SKU SKIP、另一客户买单全拣及整客户退税 SKIP,B 为唯一实际买单 2→1 PARTIAL 且退税全部 SKIP。也支持 `--fixture=` 消费本轮通过的 `manual-pda-batch-fixture` 报告,所有动作覆盖每条 PDA 明细,限定全新未领取、零拣货且跨柜不共享来源的样本。入口执行领取、原子 batch、五类证据、唯一完成、实装数量/件数/重量/体积和报关范围、原完成命令重放零漂移,以及 B 全买单报关开始/完成和退税发送拒绝;NEW 模式照片为明确标记的合成 HTTP 夹具,不是现场照片或真机证据。报告 `reports/partial-loading-actual-PARTIAL-ACTUAL-<新批次>/report.json` 使用独占目录,先保存原请求/幂等意图,凭据脱敏、单请求流;失败保留原样本,未知传输结果阻断后续命令,不自动重造、覆盖、续跑或重放历史事件。退出自己的所有会话;不操作旧客户库存、SQL、部署或船节点。B 全买单报关完成后停在合法当前状态供总控复用,不能自行推进进港、提柜或卸柜。此入口不展开 F01–F40 全量,不代替事件 ACK、下游库存、完整退税资料/XLSX及人工页面/PDA证据。直接离线测试为 `partial-loading-actual-test-env.test.mjs`。 + +当前可执行最小范围与上文完整方案分开:两柜完成/报关范围有可执行断言;事件、下游、客户资料隔离和 XLSX 必须另取实际证据,不将方案中的未执行项写成通过。 + +### 原样本恢复与准备检查点续接(追加 README 文本) + +> 本入口支持本轮明确授权的 `--fixture=RESUME:<原失败report.json>`:核对冻结 `blocked-readback.json` 的原客户、订单、入仓码、测量批次、版本及无称重基线,将旧报告/回读 SHA 写入新的恢复目录;不再资格、创建客户或下单。测量直接从原草稿续接上传、独立 SKU 明细、称重确认、提交和审核,不运行“首次扫描新增”夹具断言。multipart 适配支持文件描述对象与现成 FormData,文件字节保留、Content-Type boundary 由 fetch 生成;直接测试使用本地真实 HTTP 接收原始字节。`--fixture=PREPARED:<本轮两柜准备report.json>` 只续接这次原来源形成的两柜零拣货检查点,不重新创建任何客户、订单或柜;任务可为未分配,或已分配给当前独立 PDA 会话的 IN_PROGRESS,仍须冻结版本、全部 PENDING/零实拣及无外部作业记录。任务已发生实拣或完成后不得用此模式重跑。同来源 SKU 的零拣跳过先走正式逐明细 SKIP,再以刷新后的版本和库存快照一次 batch 处理全部剩余待拣;不能把 SKIP 与同来源的实拣放在同一 batch,也不能提交只含部分待拣明细的 batch。原完成失败必须保留命令和失败状态,生产修复与原命令恢复由总控另行协调,不自动重新造柜、改业务事实或继续人工船节点。 + +### A 原完成命令恢复与纯读取证(追加 README 文本) + +> `partial-loading-actual-test-env.mjs` 的 `--fixture=COMPLETION:` 模式仅恢复原 A command `2106716718935052290`,先从旧报告校验唯一已受理请求和准备报告 SHA;必须同时给出 `--ready-commit=ea9732749a9215e660530035c40afc69fe56a5ae`,且已收到总控匹配 Firstleg 就绪及恢复授权。请求体严格固定为 `expectedTaskVersion=2`、`operationId=PARTIAL-ACTUAL-20261004-recovery-r3-A-complete`、`remark=PARTIAL-ACTUAL-20261004-recovery-r3-A`,不使用失败后新 task version 创建另一命令。先保存两旧 LOADED owner、全跳来源及源分配数量/绑定快照,再只 POST 一次正式 complete;遇未知结果仅回查并停止,不盲重试。完成后对比旧来源版本/历史/源分配快照、正实拣集合、清单/报关实际数、Lastleg 事件建单历史和初始在途余额/流水;全部跳过来源不得 LOADED 或获得尾程/库存。B 在此模式明确暂缓,不做资格、下单、拣货、海运节点或 SQL。 +> +> 若原命令已完成,但后续取证失败,使用新的报告目录和 `--fixture=VERIFY_A:<原COMPLETION执行report.json>` 纯读模式接续,仍带相同 ready commit;该模式只允许 GET 及自有登录/退出,不再 complete。库存列表是按维度聚合,入仓归属字段为空时必须沿 `/inventory/balances/{代表id}` 详情读取真实归属;`totalPackages` 表示在仓桶,在途数核对 `inTransitPackages`,不能把在途要求加入在仓总数。公开消费建单/流水事实只证明实装事件结果,不代替 Firstleg Outbox 发布状态、ACK/Inbox SQL 审计;精确 SQL 交总控串行读取。原失败和所有恢复/读取报告均保留,不覆盖。 diff --git a/scripts/partial-loading-actual-test-env.mjs b/scripts/partial-loading-actual-test-env.mjs new file mode 100644 index 000000000..5a4e81e51 --- /dev/null +++ b/scripts/partial-loading-actual-test-env.mjs @@ -0,0 +1,975 @@ +import assert from 'node:assert/strict' +import { readFile, mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { stableSnapshot } from './admin-test-env-modules/container-customs-acceptance-matrix.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PDA = '/api/platform/v1/first-leg/pda-loading' +const LEG = '/api/platform/v1/first-leg' +const CUSTOMS = '/api/platform/v1/customs/loading-plans' +const TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] +const id = value => String(value ?? '') +const positive = value => typeof value === 'string' && /^[1-9]\d*$/.test(value) +const rows = value => Array.isArray(value) ? value : [] +const sha = value => createHash('sha256').update(value).digest('hex') +class CheckFailure extends Error {} +function need(condition, message) { if (!condition) throw new CheckFailure(message) } + +export function plan() { + return { plan: true, networkRequests: 0, businessWrites: false, + cases: ['A: refund PARTIAL 3→2 + same-owner SKIP + buy PICK + skipped refund customer', + 'B: only actual buy row PARTIAL 2→1 + all refund rows SKIP; all actual rows PARTIAL'], + upstream: 'Use --fixture=NEW to prepare isolated customers/orders/measurements and two confirmed cabinets, or consume two passing manual-pda-batch-fixture reports', + operations: ['validate both unclaimed tasks before writes', 'claim', 'one atomic batch per task', + 'upload five supplied image files', 'complete and poll', 'actual list/metrics/customs scope', + 'same original completion replay and zero drift', 'all-buy start/complete and refund-send rejection', 'logout own sessions'], + excluded: ['F01–F40 full regression', 'SQL/deployment', 'historical events or data repair', + 'browser/device proof', 'Outbox/ACK/downstream inventory evidence', 'refund document workflow and XLSX download'], + manifest: { schemaVersion: 1, runId: 'PARTIAL-ACTUAL-', environment: 'TEST', + cases: ['A', 'B'].map(kind => ({ kind, sourceReport: '', + actions: [{ itemId: '', action: 'PARTIAL', finalPickedPackageCount: kind === 'A' ? 2 : 1 }], + evidence: Object.fromEntries(TYPES.map(type => [type, ''])), sealNo: '' })) }, + execution: 'node --env-file= scripts/partial-loading-actual-test-env.mjs --execute --environment=TEST --run-id= --fixture=' } +} + +export function executionOptions(args, execArgs, env, cwd = process.cwd()) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false } + need([4,5].includes(args.length) && args[0] === '--execute' && args[1] === '--environment=TEST' + && /^--run-id=PARTIAL-ACTUAL-[A-Za-z0-9_-]{1,40}$/.test(args[2]) + && args[3].startsWith('--fixture=') && args[3].slice(10), 'Explicit TEST, new PARTIAL-ACTUAL run-id and fixture required') + need(resolve(cwd).replaceAll('\\', '/').toLowerCase() === ROOT.replaceAll('\\', '/').toLowerCase(), 'Run in the TEST backend workspace') + need(execArgs.length === 1 && execArgs[0].startsWith('--env-file=') + && resolve(execArgs[0].slice(11)).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Use the authorized TEST env-file') + need(String(env.CHONGCHONG_ENV).toUpperCase() === 'TEST', 'TEST profile required') + for (const key of ['CHONGCHONG_ADMIN_API_BASE_URL', 'CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) need(Boolean(env[key]), `Missing ${key}`) + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + need(['https:', 'http:'].includes(base.protocol) && !base.username && !base.password + && base.pathname === '/api' && !base.search && !base.hash, 'Configured TEST admin API base must end in /api') + const input = args[3].slice(10) + const completion = input.startsWith('COMPLETION:') || input.startsWith('VERIFY_A:') || input.startsWith('B_ONLY:') + need(completion ? args[4] === '--ready-commit=ea9732749a9215e660530035c40afc69fe56a5ae' : args.length === 4, + 'Original-command recovery requires explicit matching controller-ready commit') + return { execute: true, runId: args[2].slice(9), fixture: input === 'NEW' ? 'NEW' : input.startsWith('RESUME:') + ? { resumeReport: resolve(input.slice(7)) } : input.startsWith('PREPARED:') + ? { preparedReport: resolve(input.slice(9)) } : input.startsWith('B_ONLY:') ? { bOnlyReport: resolve(input.slice(7)) } + : input.startsWith('VERIFY_A:') ? { verifyAReport: resolve(input.slice(9)) } + : completion ? { completionReport: resolve(input.slice(11)) } : resolve(input), + gateway: base.origin, ...(completion ? { readyCommit: args[4].slice(15) } : {}) } +} + +export function originalCompletionCommand(failedReport) { + need(failedReport.runId === 'PARTIAL-ACTUAL-20261004-recovery-r7' && failedReport.success === false, 'Bind the original A failed execution') + const attempts = rows(failedReport.requests).filter(item => item.method === 'POST' + && item.path === `${PDA}/tasks/2106713892758491138/complete`) + need(attempts.length === 1 && attempts[0].state === 'RECEIVED' && attempts[0].http === 200 && attempts[0].code === '200', 'Exactly one accepted original completion command required') + const body = attempts[0].request + assert.deepEqual(body, { expectedTaskVersion: 2, operationId: 'PARTIAL-ACTUAL-20261004-recovery-r3-A-complete', remark: 'PARTIAL-ACTUAL-20261004-recovery-r3-A' }, 'Original immutable completion payload') + const command = attempts[0].response?.data?.completionCommand + need(id(command?.id) === '2106716718935052290' && command.operationId === body.operationId && Number(command.expectedTaskVersion) === 2, + 'Original command identity/version mismatch') + return { commandId: id(command.id), body: structuredClone(body) } +} + +const dataOf = response => { + need(response.http === 200 && String(response.body?.code) === '200', `API rejected ${response.path}: HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`) + return response.body.data +} +const selected = (value, names) => Object.fromEntries(names.map(name => [name, value?.[name] ?? null])) +export function stableSource(source) { + return { summary: selected(source.summary, ['id','tenantId','orderId','orderNo','inboundCode','processStatus','version','packageCount','totalPackageCount','splitStatus','actualGrossWeightKg','actualVolumeCbm']), + statusHistory: source.statusHistory, palletLocations: source.palletLocations } +} + +export async function sourceSnapshot(spec, request) { + const result = [] + for (const owner of spec.owners) result.push({ owner, view: dataOf(await request('GET', `/api/platform/inbounds/${owner.inboundId}?tenantId=${owner.tenantId}`)) }) + // Failed/completed tasks are not an editable inventory-snapshot surface. The + // source detail API exposes the persisted block/slot/reservation and allocation + // quantities needed to prove these already-completed owners do not settle twice. + return { sources: result, inventory: { allocations: result.map(item => ({ tenantId: item.owner.tenantId, + inboundId: item.owner.inboundId, palletLocations: item.view.palletLocations })) } } +} + +export function verifyOldOwnersUnchanged(before, after, expected) { + for (const item of before.sources) { + const current = after.sources.find(row => row.owner.inboundId === item.owner.inboundId) + need(current, 'Original source disappeared from readback') + assert.equal(stableSnapshot(stableSource(current.view)), stableSnapshot(stableSource(item.view)), 'Original owner facts/version/history/resources must not repeat') + const actual = expected.some(row => row.inboundId === item.owner.inboundId && row.actual > 0) + need(actual ? current.view.summary.processStatus === 'LOADED' : current.view.summary.processStatus !== 'LOADED', 'Whole skipped source cannot become LOADED') + } + assert.equal(stableSnapshot(after.inventory), stableSnapshot(before.inventory), 'Original domestic inventory must not be deducted again') +} + +export function verifyPhysicalFacts(expected, task) { + const actual = rows(task.items).filter(row => ['PICKED','PARTIAL'].includes(row.itemStatus) && Number(row.pickedPackageCount) > 0) + const expectedActual = expected.filter(row => row.actual > 0) + assert.deepEqual(actual.map(row => id(row.loadingPlanItemId)).sort(), expectedActual.map(row => row.itemId).sort(), 'Original physical item set') + for (const item of expectedActual) { + const row = actual.find(row => id(row.loadingPlanItemId) === item.itemId) + need(Number(row.pickedPackageCount) === item.actual && id(row.tenantId) === item.accountId && id(row.inboundId) === item.inboundId, + 'Original physical count/customer/source drift') + } +} + +async function readSmallPage(path, request) { + const page = dataOf(await request('GET', path)) + need(Array.isArray(page.records) && Number(page.page) === 1 && Number(page.size) === 200 + && Number(page.total) === page.records.length && new Set(page.records.map(row => id(row.id))).size === page.records.length, + 'Bounded new-sample page incomplete or duplicated') + return page.records +} + +export async function downstreamSnapshot(spec, request) { + const result = [] + for (const owner of spec.owners) { + const query = new URLSearchParams({ customerId: owner.tenantId, containerNo: spec.source.containerNo, page: '1', size: '200' }) + const orders = await readSmallPage(`/api/platform/v1/last-leg/orders?${query}`, request) + const code = spec.expected.find(row => row.inboundId === owner.inboundId)?.frozen.inboundCode + need(code, 'Frozen original inbound code required for inventory scope') + const inventory = new URLSearchParams({ customerId: owner.tenantId, inboundCode: code, page: '1', size: '200' }) + const groupedBalances = await readSmallPage(`/api/platform/v1/last-leg/inventory/balances?${inventory}`, request) + const balances = [] + for (const row of groupedBalances) balances.push(dataOf(await request('GET', `/api/platform/v1/last-leg/inventory/balances/${row.id}?customerId=${owner.tenantId}`))) + const ledger = await readSmallPage(`/api/platform/v1/last-leg/inventory/ledger?${inventory}`, request) + const details = [] + for (const order of orders) details.push(dataOf(await request('GET', `/api/platform/v1/last-leg/orders/${order.id}?customerId=${owner.tenantId}`))) + result.push({ owner, orders, balances, groupedBalances, ledger, details }) + } + return result +} + +export function verifyDownstream(spec, snapshot) { + let packages = 0 + for (const item of snapshot) { + const actualRows = spec.expected.filter(row => row.inboundId === item.owner.inboundId && row.actual > 0) + const expected = actualRows.reduce((sum, row) => sum + row.actual, 0) + if (!expected) { + need(!item.orders.length && !item.balances.length && !item.ledger.length, 'Skipped owner must have no tail cargo or transit inventory') + continue + } + need(item.orders.length === 1 && item.details.length === 1, 'Exactly one consumer-created tail order per actual source') + const order = item.orders[0], detail = item.details[0] + need(id(order.customerId) === item.owner.tenantId && id(order.sourceInboundId) === item.owner.inboundId + && id(order.sourceContainerBookingId) === spec.source.containerBookingId && order.containerNo === spec.source.containerNo + && Number(order.packageCount) === expected, 'Tail materialization must use exact actual customer/source/container boxes') + need(rows(detail.statusHistory).filter(row => row.toStatus === 'PENDING_ASSIGNMENT' && row.remark === '头程装柜完成自动生成').length === 1, + 'Exactly one actual loading-event creation history') + need(rows(detail.cargo).length === actualRows.length, 'Skipped SKU cannot enter frozen tail cargo') + for (const row of actualRows) { + const cargo = detail.cargo.find(cargo => cargo.skuCode === row.frozen.skuCode) + need(cargo && Number(cargo.packageCount) === row.actual, 'Tail SKU actual boxes mismatch') + } + const sum = (values, field) => values.reduce((total, row) => total + Number(row[field]), 0) + need(item.balances.every(row => row.customerId === item.owner.tenantId && row.inboundCode === rowCode(actualRows) && id(row.inboundOrderId) === id(order.id) + && Number(row.availablePackages) === 0 && Number(row.holdPackages) === 0 && Number(row.allocatedPackages) === 0 + && Number(row.pickedPackages) === 0 && Number(row.receivingPackages) === 0 && Number(row.procurementPackages) === 0) + && sum(item.balances, 'inTransitPackages') === expected && sum(item.balances, 'totalPackages') === 0, + 'Initial transit inventory must equal actual boxes only') + need(item.ledger.length === actualRows.length && sum(item.ledger, 'deltaPackages') === expected + && item.ledger.every(row => Number(row.beforePackages) === 0 && Number(row.afterPackages) === Number(row.deltaPackages) + && row.customerId === item.owner.tenantId && row.inboundCode === rowCode(actualRows) && id(row.inboundOrderId) === id(order.id) && row.businessOrderNo === order.orderNo), + 'One initial inventory movement per actual SKU; no duplicate or skipped movement') + packages += expected + } + return { actualPackages: packages, loadingEventMaterializationVerified: true, directOutboxAckAuditVerified: false } +} +const rowCode = actualRows => actualRows[0].frozen.inboundCode + +async function waitDownstream(spec, request, sleep) { + for (let attempt = 0; attempt < 30; attempt++) { + const snapshot = await downstreamSnapshot(spec, request) + const expectedOrders = spec.expected.filter(row => row.actual > 0).map(row => row.inboundId) + const active = [...new Set(expectedOrders)] + if (snapshot.reduce((sum, item) => sum + item.orders.length, 0) === active.length) return { snapshot, facts: verifyDownstream(spec, snapshot) } + await sleep(1000) + } + throw new CheckFailure('Loading event materialization did not converge; no source/event replay') +} + +export async function recoverOriginalA(spec, original, request, checkpoint, sleep = ms => new Promise(done => setTimeout(done, ms))) { + const path = `${PDA}/tasks/${spec.source.pdaTaskId}` + const task = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')) + need(task.taskStatus === 'COMPLETION_FAILED' && task.completionCommand?.status === 'FAILED' + && id(task.completionCommand.id) === original.commandId && task.completionCommand.operationId === original.body.operationId + && Number(task.completionCommand.expectedTaskVersion) === original.body.expectedTaskVersion, 'Original failed command no longer matches; do not issue another command') + verifyPhysicalFacts(spec.expected, task) + const before = await sourceSnapshot(spec, request) + for (const item of before.sources) { + const actual = spec.expected.some(row => row.inboundId === item.owner.inboundId && row.actual > 0) + need(actual ? item.view.summary.processStatus === 'LOADED' : item.view.summary.processStatus !== 'LOADED', 'Original completed/whole-skipped owner baseline mismatch') + } + await checkpoint({ stage: 'A_BEFORE_ORIGINAL_RECOVERY', task, original, before }) + try { dataOf(await request('POST', `${path}/complete`, original.body, 'pda')) } + catch (error) { + const readback = await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda') + await checkpoint({ stage: 'A_RECOVERY_REJECTED_OR_UNKNOWN_READBACK', readback }) + throw error // no blind repetition of a write with an uncertain result + } + let completed + for (let attempt = 0; attempt < 60; attempt++) { + completed = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')) + if (completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED') break + need(completed.taskStatus === 'COMPLETING', `Original recovery failed: ${completed.completionCommand?.failureStage}/${completed.completionCommand?.failureCode}`) + await sleep(1000) + } + need(completed.taskStatus === 'COMPLETED' && id(completed.completionCommand.id) === original.commandId, 'Same original command must finish') + verifyPhysicalFacts(spec.expected, completed) + const after = await sourceSnapshot(spec, request); verifyOldOwnersUnchanged(before, after, spec.expected) + const detail = dataOf(await request('GET', `${LEG}/loading-lists/${spec.source.loadingPlanId}?warehouseId=${spec.source.warehouseId}`)) + const customs = dataOf(await request('GET', `${CUSTOMS}/${spec.source.loadingPlanId}`)) + const planState = dataOf(await request('GET', `${LEG}/loading-plans/${spec.source.loadingPlanId}/lifecycle?warehouseId=${spec.source.warehouseId}`)) + const facts = verifyActual(spec.expected, detail, customs) + need(planState.detailResource === 'LOADING_LIST' && planState.planStatus === 'COMPLETED', 'Recovered plan must expose completed loading-list lifecycle') + const downstream = await waitDownstream(spec, request, sleep) + await checkpoint({ stage: 'A_AFTER_ORIGINAL_RECOVERY', completed, after, detail, customs, planState, downstream }) + const operations = dataOf(await request('GET', `${path}/operations`, undefined, 'pda')) + need(rows(operations).filter(row => row.actionType === 'COMPLETE_LOADING').length === 1, 'Recovered completion audit must be unique') + return { kind: 'A', ...facts, ...downstream.facts, originalCommandId: original.commandId, + oldOwnersAndDomesticInventoryUnchanged: true, wholeSkippedOwnerNotLoaded: true, completed: true } +} + +export async function verifyRecoveredA(spec, original, before, request, checkpoint, sleep = ms => new Promise(done => setTimeout(done, ms))) { + const path = `${PDA}/tasks/${spec.source.pdaTaskId}` + const completed = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')) + need(completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED' + && completed.completionCommand?.status === 'COMPLETED' && id(completed.completionCommand.id) === original.commandId + && completed.completionCommand.operationId === original.body.operationId + && Number(completed.completionCommand.expectedTaskVersion) === original.body.expectedTaskVersion, 'Same recovered original command must remain completed') + verifyPhysicalFacts(spec.expected, completed) + const after = await sourceSnapshot(spec, request); verifyOldOwnersUnchanged(before, after, spec.expected) + const detail = dataOf(await request('GET', `${LEG}/loading-lists/${spec.source.loadingPlanId}?warehouseId=${spec.source.warehouseId}`)) + const customs = dataOf(await request('GET', `${CUSTOMS}/${spec.source.loadingPlanId}`)) + const plan = dataOf(await request('GET', `${LEG}/loading-plans/${spec.source.loadingPlanId}/lifecycle?warehouseId=${spec.source.warehouseId}`)) + const facts = verifyActual(spec.expected, detail, customs) + need(plan.detailResource === 'LOADING_LIST' && plan.planStatus === 'COMPLETED', 'Completed original plan must remain completed') + const downstream = await waitDownstream(spec, request, sleep) + const operations = dataOf(await request('GET', `${path}/operations`, undefined, 'pda')) + need(rows(operations).filter(row => row.actionType === 'COMPLETE_LOADING').length === 1, 'One original completion audit') + await checkpoint({ stage: 'A_READONLY_VERIFIED_AFTER_ORIGINAL_RECOVERY', completed, after, detail, customs, plan, downstream, operations }) + return { kind: 'A', ...facts, ...downstream.facts, originalCommandId: original.commandId, readOnlyVerification: true, + oldOwnersAndDomesticAllocationSnapshotsUnchanged: true, wholeSkippedOwnerNotLoaded: true, completed: true } +} + +export function validateZeroWorkTask(source, task, ownUserId) { + need(id(task.id) === source.pdaTaskId && id(task.loadingPlanId) === source.loadingPlanId + && id(task.containerBookingId) === source.containerBookingId && task.currentContainerNo === source.containerNo + && task.taskStatus === 'LOADING' && Number(task.version) === Number(source.beforeTaskVersion), 'Original task identity/version changed') + need(task.workflowStatus === 'UNASSIGNED' && task.assignment == null + || task.workflowStatus === 'IN_PROGRESS' && id(task.assignment?.assigneeId) === ownUserId, + 'Task must be unassigned or assigned to this own PDA session') + need(rows(task.items).length > 0 && rows(task.items).every(item => item.itemStatus === 'PENDING' + && Number(item.pickedPackageCount) === 0), 'Prepared task already has physical work') +} + +export function validatePreparedResume(report) { + need(report.runId === 'PARTIAL-ACTUAL-20261004-recovery-r3' && report.success === false && rows(report.cases).length === 0 + && report.recovery?.originalOwner?.inboundId === '2106706513799155714' && report.recovery?.originalSha256, + 'Only this original-source two-cabinet preparation may continue') + need(!rows(report.requests).some(item => item.method === 'POST' && /\/pda-loading\/tasks\/\d+\/(claim|batch|complete|evidence)$/.test(item.path)), + 'Preparation continuation cannot repeat any PDA work') + const prepared = rows(report.preparation).filter(item => item.stage === 'PDA_UNASSIGNED') + need(prepared.length === 2 && prepared[0].kind === 'A' && prepared[1].kind === 'B' + && prepared.every(item => item.source?.sourceMarker === `${report.runId}-${item.kind}` && item.spec?.kind === item.kind), 'Exactly the prepared A/B checkpoints required') + return prepared +} + +export function multipartBody(file) { + if (file instanceof FormData) return file + need(file?.content !== undefined, 'Multipart descriptor requires content bytes') + const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) + const form = new FormData() + form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') + return form +} + +export function requestInit(method, body, token, extraHeaders = {}) { + const headers = new Headers({ Accept: 'application/json', ...extraHeaders }) + if (token) headers.set('Authorization', `Bearer ${token}`) + if (body instanceof FormData) headers.delete('Content-Type') // fetch supplies the actual boundary + else if (body !== undefined) headers.set('Content-Type', 'application/json') + return { method, redirect: 'error', signal: AbortSignal.timeout(30_000), headers, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) } +} + +export function multipartAdapter(request, roleFor) { + return (method, path, file, token, headers = {}) => request(method, path, multipartBody(file), roleFor(token), headers) +} + +export function validateResumeProof(report, readback) { + const owner = { tenantId: '2106706477225971713', orderId: '2106706495895306242', inboundId: '2106706513799155714', + orderNo: 'FL2106706495895306242', inboundCode: 'NYEXX2610040025', marker: 'PARTIAL-ACTUAL-20261004-r1-A-REFUND' } + need(report.runId === 'PARTIAL-ACTUAL-20261004-r1' && report.success === false && rows(report.cases).length === 0, + 'Only the explicitly authorized failed original run may resume') + const rejected = rows(report.requests).filter(item => item.path === `/api/platform/v1/files/measurements/${owner.tenantId}/${owner.inboundId}`) + need(rejected.length === 1 && rejected[0].http === 200 && rejected[0].code === 'S0500' && rejected[0].requestId, 'Original one rejected upload required') + const summary = readback.inbound?.summary, measurement = readback.measurement + need(readback.verified === true && readback.sessionClosed === true && id(summary?.id) === owner.inboundId + && id(summary?.tenantId) === owner.tenantId && id(summary?.orderId) === owner.orderId && summary?.orderNo === owner.orderNo + && summary?.inboundCode === owner.inboundCode && summary?.processStatus === 'WAIT_MEASURE' + && measurement?.measurementStatus === 'DRAFT' && rows(measurement.weightResults).length === 0, 'Frozen readback must match original WAIT_MEASURE/DRAFT without weights') + return owner +} + +export function validateResumeLive(owner, frozen, inbound, measurement) { + const summary = inbound?.summary + need(id(summary?.id) === owner.inboundId && id(summary?.tenantId) === owner.tenantId && id(summary?.orderId) === owner.orderId + && summary?.orderNo === owner.orderNo && summary?.inboundCode === owner.inboundCode + && summary?.processStatus === 'WAIT_MEASURE' && measurement?.measurementStatus === frozen.measurement.measurementStatus + && ['DRAFT', 'RECEIVED'].includes(measurement?.measurementStatus) + && rows(measurement.weightResults).length === 0 && id(measurement.measurementId) === id(frozen.measurement.measurementId) + && measurement.version === frozen.measurement.version && summary.version === frozen.inbound.summary.version, + 'Original sample changed; stop before any recovery write') +} + +export async function resumeMeasurementDraft(context, owner, initial) { + const root = `${LEG}/inbounds/${owner.inboundId}`, query = `tenantId=${owner.tenantId}` + const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Original measurement rejected ${response.path}`); return response.body.data } + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + const uploaded = ok(await context.requestMultipart('POST', `/api/platform/v1/files/measurements/${owner.tenantId}/${owner.inboundId}`, + { content: bytes, type: 'image/png', name: `recovery-weight-${owner.inboundId}.png` })) + need(uploaded.fileId, 'Original recovery upload did not return a file') + const declarations = rows(initial.latestDeclaration?.items) + need(declarations.length === 2 && declarations.map(item => Number(item.packageCount)).sort().join(',') === '1,3', 'Original two-SKU declaration must remain 3+1') + // Save the two declared SKU identities separately. The generic single-SKU fixture + // had aggregated all four boxes into the first detail; no carton scanning is repeated. + const draft = ok(await context.request('PUT', `${root}/measurement/draft?${query}`, { + measurementId: initial.measurementId, version: initial.version, actualGoodsName: '原样本两SKU恢复测量', + items: declarations.map(item => ({ inboundDetailId: item.inboundDetailId, itemNo: String(item.itemNo), + boxSpecification: item.cargoName, packageCount: Number(item.packageCount), + lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm), + intelligentImageFileId: uploaded.fileId, coefficientMode: 'ACTUAL', remark: owner.marker })), + })) + const options = ok(await context.request('GET', `${root}/weight-pallet-options?${query}&page=1&size=20&keyword=YW-TP-002`)) + const pallet = rows(options.records).find(item => item.palletCode === 'YW-TP-002') + need(pallet?.palletId, 'Original source warehouse needs the existing YW-TP-002 pallet') + const weight = ok(await context.request('POST', `${root}/weight-results?${query}`, { fileId: id(uploaded.fileId), + palletId: id(pallet.palletId), ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null })) + const confirmed = ok(await context.request('POST', `${root}/weight-results/${weight.id}/confirm?${query}`, { + version: weight.version, adjustedWeightValue: 100, adjustedWeightUnit: 'KG', adjustmentReason: `${owner.marker} HTTP人工称重夹具` })) + need(confirmed.recognitionStatus === 'CONFIRMED', 'Original source weight must be confirmed') + const current = ok(await context.request('GET', `${root}/measurement?${query}`)) + need(id(current.measurementId) === id(draft.measurementId) && current.measurementStatus === 'DRAFT', 'Original draft identity retained') + const submitted = ok(await context.request('POST', `${root}/measurement/submit?${query}`, { measurementId: current.measurementId, version: current.version })) + need(submitted.processStatus === 'MEASURE_PENDING', 'Original draft must enter actual measurement review') + const pending = ok(await context.request('GET', `/api/platform/inbounds/${owner.inboundId}?${query}`)) + ok(await context.request('POST', `/api/platform/v1/inbounds/${owner.inboundId}/measurements/approve?${query}`, + { measurementId: current.measurementId, expectedVersion: pending.summary.version, reason: null }, context.token, + { 'Idempotency-Key': `${owner.marker}-recovery-approve` })) + let final + for (let attempt = 0; attempt < 30; attempt++) { + final = ok(await context.request('GET', `${root}/measurement?${query}`)) + if (final.processStatus === 'WAIT_SPLIT' && final.measurementStatus === 'APPROVED') break + await new Promise(done => setTimeout(done, 1000)) + } + need(final.processStatus === 'WAIT_SPLIT' && final.measurementStatus === 'APPROVED', 'Original recovery did not reach WAIT_SPLIT') + need(rows(final.items).length === 2 && rows(final.items).every(item => declarations.some(declaration => + id(declaration.inboundDetailId) === id(item.inboundDetailId) && Number(declaration.packageCount) === Number(item.packageCount))), 'Original SKU measurements retain declaration identities and 3+1 counts') + context.createdResources.inboundMeasurement = { marker: owner.marker, tenantId: owner.tenantId, inboundId: owner.inboundId, + orderId: owner.orderId, inboundCode: owner.inboundCode, measurementId: id(final.measurementId), + finalStatus: final.measurementStatus, processStatus: final.processStatus } +} + +export function redact(value) { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => + [key, /password|token|secret|authorization/i.test(key) ? '[REDACTED]' : redact(child)])) + return value +} + +// This is a bounded producer, not a run of the full acceptance module list. +// The three reused modules only create qualified customer + submitted/approved/measured order. +export async function prepareFreshCases(runId, context, savePrepared, resume = null) { + const { run: qualification } = await import('./admin-test-env-modules/merchant-qualification-fixture.mjs') + const { run: orders } = await import('./admin-test-env-modules/orders.mjs') + const { run: measurement } = await import('./admin-test-env-modules/inbound-measurement.mjs') + const { selectSessionPdaOperator } = await import('./admin-test-env-modules/domestic-pda-operator.mjs') + const { publishPlatformLogisticsChannels, resolveOceanBookingAgent } = await import('./admin-test-env-modules/ocean-booking-fixture.mjs') + const { ensureMatsonCarrierPartner } = await import('./admin-test-env-modules/carrier-partner-fixture.mjs') + const { channelGroup } = await import('./admin-test-env-modules/smart-loading-channel-group.mjs') + const { iso6346ContainerNumber } = await import('./admin-test-env-modules/iso6346-container-number.mjs') + const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Fresh fixture rejected ${response.path}`); return response.body.data } + const produced = [] + for (const [kind, definitions] of [['A', [['REFUND', 'TAX_REFUND', [3,1]], ['BUY', 'BUY_DECLARATION', [1]], ['SKIP', 'TAX_REFUND', [1]]]], + ['B', [['BUY', 'BUY_DECLARATION', [2]], ['SKIP', 'TAX_REFUND', [1]]]]]) { + const owners = [] + for (const [role, taxMode, counts] of definitions) { + context.marker = `${runId}-${kind}-${role}` + context.context = { orderProfile: { taxMode, fulfillmentType: 'STANDARD' }, orderFulfillmentType: 'STANDARD', + waitSplitVariant: { skuCount: counts.length, packageCounts: counts, packageCountPerSku: counts[0] } } + context.createdResources = {} + const originalRecovery = resume && kind === 'A' && role === 'REFUND' + let originalMeasurement + if (originalRecovery) { + const original = resume.owner + const inbound = ok(await context.request('GET', `/api/platform/inbounds/${original.inboundId}?tenantId=${original.tenantId}`)) + const measurementView = ok(await context.request('GET', `${LEG}/inbounds/${original.inboundId}/measurement?tenantId=${original.tenantId}`)) + validateResumeLive(original, resume.readback, inbound, measurementView) + context.marker = original.marker + context.createdResources.orders = { ...original, receivingWarehouseId: id(inbound.summary.warehouseId), + fulfillmentType: 'STANDARD', taxMode: 'TAX_REFUND' } + await savePrepared({ stage: 'ORIGINAL_RECOVERY_CHECKED', kind, role, ...original, measurementId: measurementView.measurementId, + inboundVersion: inbound.summary.version, measurementVersion: measurementView.version }) + originalMeasurement = measurementView + } else { + await qualification(context, { publishEnvironment: false }) + await orders(context) + } + await savePrepared({ stage: 'APPROVED_ORDER', kind, role, tenantId: id(context.createdResources.orders?.tenantId), + inboundId: id(context.createdResources.orders?.inboundId), orderId: id(context.createdResources.orders?.orderId) }) + if (originalRecovery) await resumeMeasurementDraft(context, resume.owner, originalMeasurement) + else await measurement(context) + const source = context.createdResources.inboundMeasurement + need(source?.inboundId && source.processStatus === 'WAIT_SPLIT' && source.finalStatus === 'APPROVED', 'New legal measurement did not reach WAIT_SPLIT') + owners.push({ role, taxMode, counts, tenantId: id(source.tenantId), inboundId: id(source.inboundId), + orderId: id(source.orderId || context.createdResources.orders?.orderId), orderNo: context.createdResources.orders?.orderNo, + inboundCode: source.inboundCode || context.createdResources.orders?.inboundCode }) + await savePrepared({ stage: 'WAIT_SPLIT', kind, owners: structuredClone(owners) }) + } + context.marker = `${runId}-${kind}` + const primary = owners[0] + const inbound = ok(await context.request('GET', `/api/platform/inbounds/${primary.inboundId}?tenantId=${primary.tenantId}`)) + const warehouseId = id(inbound.summary.warehouseId), channelCode = inbound.summary.channelCode + need(warehouseId && channelCode, 'Fresh source warehouse/channel missing') + await publishPlatformLogisticsChannels(context, warehouseId, 'partial-loading-prepare') + const agent = await resolveOceanBookingAgent(context, 'partial-loading-prepare', 'MATSON', { deploymentBlocker: true }) + const carrier = await ensureMatsonCarrierPartner(context, 'partial-loading-prepare', { deploymentBlocker: true }) + need(agent?.id && carrier?.id, 'Existing booking master data required') + const container = ok(await context.request('POST', '/api/platform/v1/containers', { + channelGroupCode: channelGroup(channelCode), warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })) + const bookingId = id(container.id) + let smartPlan = ok(await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/by-booking/${bookingId}`)) + const planId = id(smartPlan.id) + await savePrepared({ stage: 'EDITING', kind, owners, containerBookingId: bookingId, loadingPlanId: planId }) + const candidateResponse = ok(await context.request('GET', `/api/platform/v1/first-leg/smart-loading/candidates?bookingId=${bookingId}&warehouseId=${warehouseId}`)) + const candidates = rows(candidateResponse) + for (const owner of owners) { + const own = candidates.filter(item => id(item.inboundId) === owner.inboundId && id(item.accountId) === owner.tenantId) + .sort((a,b) => id(a.inboundDetailId).localeCompare(id(b.inboundDetailId))) + need(own.length === owner.counts.length, 'Fresh owner candidate SKU set mismatch') + for (const candidate of own) { + const packages = Number(candidate.availablePackageCount) + need(owner.counts.includes(packages), 'Candidate count differs from fresh declared boxes') + smartPlan = ok(await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${planId}/assignments`, { + inboundId: owner.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: packages, + expectedVersion: Number(smartPlan.version), operationId: `${context.marker}-assign-${candidate.inboundDetailId}` })) + } + } + const booking = ok(await context.request('POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: { + channelCode: channelGroup(channelCode), warehouseId, billOfLadingNo: `BL-${context.marker}`, + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', + bookingAgentId: id(agent.id), bookingAgentName: agent.name, vesselName: `测试船-${context.marker}`, voyageNo: 'PA-TEST', + departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', + estimatedSailingAt: '2026-10-05T08:00:00', estimatedArrivalAt: '2026-10-25T08:00:00', remark: context.marker, + parties: ['SHIPPER','CONSIGNEE','NOTIFY_PARTY'].map(partyType => ({ partyType, partyName: `${context.marker}-${partyType}` })), + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }], + } }, context.token, { 'Idempotency-Key': `${context.marker}-booking` })) + const oceanId = id(booking.oceanBooking.id), form = new FormData() + form.append('expectedVersion', String(booking.oceanBooking.version)); form.append('documentType', 'BOOKING_CONFIRMATION') + form.append('file', new Blob([`${context.marker} controlled TEST booking confirmation`], { type: 'text/plain' }), `${context.marker}-booking.txt`) + const document = ok(await context.requestMultipart('POST', `/api/platform/v1/ocean-bookings/${oceanId}/documents/upload`, form, + context.token, { 'Idempotency-Key': `${context.marker}-booking-file` })) + const released = ok(await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanId}/release`, + { expectedVersion: Number(document.bookingVersion), reason: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-release` })) + need(released.oceanBooking.status === 'RELEASED', 'New booking release required') + const currentContainer = ok(await context.request('GET', `/api/platform/v1/containers/domestic/${bookingId}`)).container + const containerNo = iso6346ContainerNumber(context.marker) + ok(await context.request('POST', `/api/platform/v1/containers/${bookingId}/ocean-booking-link`, { + oceanBookingId: oceanId, requirementId: id(released.containerRequirements[0].id), containerNo, expectedVersion: currentContainer.version })) + const preload = ok(await context.request('GET', `${LEG}/loading-plans/${planId}?warehouseId=${warehouseId}`)) + const operator = selectSessionPdaOperator(ok(await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${warehouseId}`)), context.pdaToken) + const confirmation = ok(await context.request('POST', `${LEG}/loading-plans/${planId}/confirm`, + { expectedVersion: Number(preload.summary.version), assigneeId: id(operator.userId) }, context.token, { 'Idempotency-Key': `${context.marker}-confirm` })) + const taskId = id(confirmation.readiness.pdaTaskId) + const task = ok(await context.request('GET', `${PDA}/tasks/${taskId}?warehouseId=${warehouseId}`, undefined, context.pdaToken)) + const source = { schemaVersion: 1, sourceMarker: context.marker, preparedAt: new Date().toISOString(), stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', + pdaTaskId: taskId, loadingPlanId: planId, containerBookingId: bookingId, warehouseId, containerNo, + cabinetNo: container.cabinetNo, oceanBookingId: oceanId, bookingNo: released.oceanBooking.bookingNo, planNo: smartPlan.planNo, + beforeTaskVersion: task.version, taskItems: task.items.map(item => ({ id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId), + tenantId: id(item.tenantId), inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), plannedPackageCount: item.plannedPackageCount })) } + const actions = task.items.map(item => { + const owner = owners.find(owner => owner.inboundId === id(item.inboundId)) + need(owner, 'Task includes only fresh produced owners') + const partial = owner === primary && Number(item.plannedPackageCount) === (kind === 'A' ? 3 : 2) + return { itemId: id(item.id), action: partial ? 'PARTIAL' : owner.role === 'BUY' ? 'PICK' : 'SKIP', + ...(partial ? { finalPickedPackageCount: kind === 'A' ? 2 : 1 } : {}) } + }) + const spec = { kind, actions, sealNo: `SEAL-${context.marker}` } + const freshPreload = ok(await context.request('GET', `${LEG}/loading-plans/${planId}?warehouseId=${warehouseId}`)) + expectedRows(spec, task, freshPreload) + produced.push({ spec, source, owners }) + await savePrepared({ stage: 'PDA_UNASSIGNED', kind, source, owners, spec }) + } + return produced +} + +export function validateManifest(manifest, runId) { + need(manifest?.schemaVersion === 1 && manifest.environment === 'TEST' && manifest.runId === runId, 'Fixture run/environment mismatch') + need(rows(manifest.cases).length === 2 && manifest.cases[0].kind === 'A' && manifest.cases[1].kind === 'B', 'Exactly A then B required') + for (const item of manifest.cases) { + need(typeof item.sourceReport === 'string' && item.sourceReport.length > 0, 'New official fixture source report required') + need(rows(item.actions).length >= 2 && new Set(item.actions.map(row => row.itemId)).size === item.actions.length, 'Every PDA item needs one unique action') + for (const action of item.actions) need(positive(action.itemId) && ['PICK', 'PARTIAL', 'SKIP'].includes(action.action) + && (action.action !== 'PARTIAL' || Number.isSafeInteger(action.finalPickedPackageCount) && action.finalPickedPackageCount > 0), 'Invalid PDA action') + need(typeof item.sealNo === 'string' && item.sealNo.trim().length > 0 && item.sealNo.length <= 64, 'Seal required') + need(TYPES.every(type => typeof item.evidence?.[type] === 'string' && item.evidence[type]), 'Five supplied evidence paths required') + } + return manifest +} + +export function fixtureSource(report, runId, gateway, now = Date.now()) { + const value = report?.createdResources?.manualPdaBatchFixture + need(report.success === true && report.fatal == null && report.summary?.failed === 0 + && report.gateway?.replace(/\/$/, '') === gateway, 'Official fixture report did not pass in this TEST Gateway') + need(value?.schemaVersion === 1 && value.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' + && typeof value.sourceMarker === 'string' && value.sourceMarker.startsWith(`${runId}-`) + && /^[A-Za-z0-9_-]{1,80}$/.test(value.sourceMarker), 'Fresh run-bound PDA preparation required') + const age = now - Date.parse(value.preparedAt) + need(Number.isFinite(age) && age >= 0 && age <= 24 * 3600_000, 'Fixture must be prepared within this run/day') + for (const key of ['pdaTaskId', 'loadingPlanId', 'containerBookingId', 'warehouseId']) need(positive(value[key]), `Invalid source ${key}`) + need(rows(value.taskItems).length > 0, 'Missing original PDA identities') + return value +} + +// Decimal arithmetic is exact; no binary floating point at HALF_UP boundaries. +export function scale(value, actual, planned, precision) { + if (value == null) return null + const text = String(value) + need(/^\d+(?:\.\d+)?$/.test(text) && Number.isSafeInteger(actual) && actual > 0 + && Number.isSafeInteger(planned) && planned >= actual, 'Invalid nonnegative frozen decimal/ratio') + const [whole, fraction = ''] = text.split('.') + const numerator = BigInt(whole + fraction) * BigInt(actual) * 10n ** BigInt(precision) + const denominator = BigInt(planned) * 10n ** BigInt(fraction.length) + const rounded = (2n * numerator + denominator) / (2n * denominator) + const padded = rounded.toString().padStart(precision + 1, '0') + return precision ? `${padded.slice(0, -precision)}.${padded.slice(-precision)}` : padded +} + +export function expectedRows(caseSpec, task, preload) { + const byId = new Map(rows(task.items).map(item => [id(item.id), item])) + need(byId.size === task.items.length && byId.size === caseSpec.actions.length + && caseSpec.actions.every(item => byId.has(item.itemId)), 'Actions must cover every live PDA item exactly once') + const planById = new Map(rows(preload.items).map(item => [id(item.itemId), item])) + need(planById.size === byId.size, 'Preload and PDA scope mismatch') + const expected = caseSpec.actions.map(action => { + const pick = byId.get(action.itemId), frozen = planById.get(id(pick.loadingPlanItemId)) + need(frozen && id(frozen.accountId) === id(pick.tenantId) && id(frozen.inboundId) === id(pick.inboundId) + && id(frozen.inboundDetailId) === id(pick.inboundDetailId), 'Frozen source/customer/detail identity mismatch') + const planned = Number(pick.plannedPackageCount) + need(pick.itemStatus === 'PENDING' && Number(pick.pickedPackageCount) === 0 && pick.unitType === 'PACKAGE' + && !pick.palletId && planned > 0 && Number(frozen.packageCount) === planned, 'Require zero-picked PACKAGE sources') + const actual = action.action === 'SKIP' ? 0 : action.action === 'PICK' ? planned : action.finalPickedPackageCount + need(action.action !== 'PARTIAL' || actual < planned, 'PARTIAL must be less than original plan') + return { action: action.action, pickId: action.itemId, itemId: id(frozen.itemId), accountId: id(frozen.accountId), + inboundId: id(frozen.inboundId), inboundDetailId: id(frozen.inboundDetailId), taxMode: frozen.customsTaxMode, + planned, actual, frozen, orderId: id(frozen.orderId) } + }) + const partial = expected.filter(item => item.action === 'PARTIAL'), full = expected.filter(item => item.action === 'PICK') + const skipped = expected.filter(item => item.action === 'SKIP'), active = expected.filter(item => item.actual > 0) + need(expected.every(item => ['TAX_REFUND', 'BUY_DECLARATION'].includes(item.taxMode)), 'Authoritative tax mode required') + if (caseSpec.kind === 'A') { + need(partial.length === 1 && partial[0].taxMode === 'TAX_REFUND' && partial[0].planned === 3 && partial[0].actual === 2 + && full.length === 1 && full[0].taxMode === 'BUY_DECLARATION' && full[0].accountId !== partial[0].accountId + && skipped.some(item => item.inboundId === partial[0].inboundId) + && skipped.some(item => item.taxMode === 'TAX_REFUND' && !active.some(row => row.accountId === item.accountId)), 'A needs mixed customers, partial refund, same-source skip and entirely skipped refund customer') + } else { + need(active.length === 1 && partial.length === 1 && full.length === 0 + && partial[0].taxMode === 'BUY_DECLARATION' && partial[0].planned === 2 && partial[0].actual === 1 + && skipped.length > 0 && skipped.every(item => item.taxMode === 'TAX_REFUND'), 'B must have exactly one actual BUY PARTIAL row and only skipped refund rows') + } + return expected +} + +export function verifyActual(expected, detail, customs) { + const active = expected.filter(item => item.actual > 0) + assert.deepEqual(rows(detail.items).map(item => id(item.itemId)).sort(), active.map(item => item.itemId).sort(), 'Actual loading row set') + for (const item of active) { + const actual = detail.items.find(row => id(row.itemId) === item.itemId), frozen = item.frozen + assert.equal(Number(actual.packageCount), item.actual, 'Actual boxes') + assert.equal(id(actual.accountId), item.accountId, 'Actual customer') + assert.equal(id(actual.inboundId), item.inboundId, 'Actual source') + const units = Number(frozen.unitsPerPackage) + const quantity = item.actual === item.planned ? Number(frozen.productQuantity) + : units > 0 ? units * item.actual : Number(scale(frozen.productQuantity, item.actual, item.planned, 0)) + assert.equal(Number(actual.productQuantity), quantity, 'Actual units') + for (const [field, precision] of [['grossWeightKg', 3], ['netWeightKg', 3], ['dimensionalWeightKg', 3], ['volumeCbm', 4]]) + assert.equal(scale(actual[field], 1, 1, precision), scale(frozen[field], item.actual, item.planned, precision), `Actual ${field}`) + } + const boxes = active.reduce((sum, item) => sum + item.actual, 0) + assert.equal(Number(detail.summary.totalPackageCount), boxes, 'Actual total boxes') + assert.equal(Number(customs.actualBoxCount), boxes, 'Customs actual boxes') + const inboundKeys = values => [...new Set(values.map(item => `${id(item.tenantId ?? item.accountId)}:${id(item.inboundId)}`))].sort() + assert.deepEqual(inboundKeys(rows(customs.inbounds)), inboundKeys(active), 'Customs source/customer set') + assert.equal(Number(customs.taxRefundCount), new Set(active.filter(item => item.taxMode === 'TAX_REFUND').map(item => item.inboundId)).size, 'Refund coverage excludes skipped rows') + assert.equal(Number(detail.summary.totalQuantity), detail.items.reduce((sum, item) => sum + Number(item.productQuantity), 0), 'Quantity total') + for (const [field, summary, precision] of [['grossWeightKg', 'totalGrossWeightKg', 3], ['netWeightKg', 'totalNetWeightKg', 3], ['volumeCbm', 'totalVolumeCbm', 4]]) { + const sum = detail.items.reduce((value, item) => value + BigInt(scale(item[field] ?? 0, 1, 1, precision).replace('.', '')), 0n) + assert.equal(BigInt(scale(detail.summary[summary] ?? 0, 1, 1, precision).replace('.', '')), sum, `Actual ${summary}`) + } + return { actualBoxes: boxes, actualRows: active.length, taxRefundCount: Number(customs.taxRefundCount) } +} + +export async function runCase(spec, source, prepared, request, sleep = ms => new Promise(done => setTimeout(done, ms))) { + const { expected, task, images } = prepared + const path = `${PDA}/tasks/${source.pdaTaskId}`, key = suffix => `${source.sourceMarker}-${suffix}`.slice(0, 128) + const data = response => { need(response.http === 200 && String(response.body?.code) === '200', `API rejected ${response.path}: HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`); return response.body.data } + const readTask = async () => data(await request('GET', `${path}?warehouseId=${source.warehouseId}`, undefined, 'pda')) + const claimed = data(await request('POST', `${path}/claim`, { expectedAssignmentVersion: Number(task.assignment?.version ?? 0), operationId: key('claim') }, 'pda')) + need(claimed.workflowStatus === 'IN_PROGRESS', 'Claim must enter IN_PROGRESS') + let picked + // Batch must contain every still-pending row, and forbids SKIP + physical picks + // for one owner in the same command. Resolve that owner's zero-picked SKU through + // the formal individual SKIP first; then one atomic batch handles all remaining rows. + const individualSkips = expected.filter(item => item.action === 'SKIP' + && expected.some(other => other.inboundId === item.inboundId && other.actual > 0)) + for (const item of individualSkips) { + const current = await readTask(), row = current.items.find(row => id(row.id) === item.pickId) + data(await request('POST', `${path}/items/${item.pickId}/skip`, { warehouseId: source.warehouseId, + version: Number(row.version), operationId: key(`skip-${item.pickId}`), reason: source.sourceMarker }, 'pda')) + } + const lines = expected.filter(item => !individualSkips.some(skip => skip.pickId === item.pickId)) + { + const current = await readTask() + const inventory = data(await request('GET', `${path}/inventory-snapshot?warehouseId=${source.warehouseId}`, undefined, 'pda')) + const batch = { warehouseId: source.warehouseId, loadingPlanId: source.loadingPlanId, containerBookingId: source.containerBookingId, + expectedVersion: Number(current.version), inventorySnapshotToken: inventory.snapshotToken, + items: lines.map(item => ({ itemId: item.pickId, action: item.action, + version: Number(current.items.find(row => id(row.id) === item.pickId)?.version), + ...(item.action === 'PARTIAL' ? { finalPickedPackageCount: item.actual } : item.action === 'PICK' ? { pickedPackageCount: item.actual } : { reason: source.sourceMarker }) })) } + need(inventory.snapshotToken && batch.items.every(item => Number.isSafeInteger(item.version)), 'Fresh inventory and item versions required') + picked = data(await request('POST', `${path}/batch`, batch, 'pda', { 'Idempotency-Key': key('resolved-batch') })) + } + need(picked.workflowStatus === 'AWAITING_REVIEW' && expected.every(item => { + const row = rows(picked.items).find(row => id(row.id) === item.pickId) + return row && Number(row.pickedPackageCount) === item.actual && row.itemStatus === ({ PICK: 'PICKED', PARTIAL: 'PARTIAL', SKIP: 'SKIPPED' })[item.action] + }), 'Resolved picks and all-PARTIAL B must be persisted before completion') + for (const type of TYPES) { + const image = images[type], form = new FormData() + for (const [name, value] of Object.entries({ warehouseId: source.warehouseId, expectedVersion: 0, evidenceType: type, + remark: source.sourceMarker, ...(type === 'SEAL' ? { sealNo: spec.sealNo } : {}) })) form.append(name, String(value)) + form.append('file', new Blob([image.bytes], { type: image.type }), `${source.sourceMarker}-${type}.${image.type === 'image/png' ? 'png' : 'jpg'}`) + data(await request('POST', `${path}/evidence`, form, 'pda', { 'Idempotency-Key': key(`evidence-${type}`) })) + } + const evidence = data(await request('GET', `${path}/evidence?warehouseId=${source.warehouseId}`, undefined, 'pda')) + need(TYPES.every(type => rows(evidence.evidences).some(item => item.evidenceType === type && item.status === 'ACTIVE' + && (type !== 'SEAL' || item.sealNo === spec.sealNo))), 'Five active evidence types and seal required') + const ready = await readTask(), body = { expectedTaskVersion: Number(ready.version), operationId: key('complete'), remark: source.sourceMarker } + data(await request('POST', `${path}/complete`, body, 'pda')) + let completed + for (let attempt = 0; attempt < 60; attempt++) { + completed = await readTask() + if (completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED') break + need(completed.taskStatus === 'COMPLETING', `Completion stopped: task=${completed.taskStatus}, command=${completed.completionCommand?.status ?? 'missing'}, stage=${completed.completionCommand?.failureStage ?? 'missing'}, code=${completed.completionCommand?.failureCode ?? 'missing'}`) + await sleep(1000) + } + need(completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED', 'Completion polling exhausted') + const snapshot = async () => ({ task: await readTask(), + detail: data(await request('GET', `${LEG}/loading-lists/${source.loadingPlanId}?warehouseId=${source.warehouseId}`)), + customs: data(await request('GET', `${CUSTOMS}/${source.loadingPlanId}`)), + operations: data(await request('GET', `${path}/operations`, undefined, 'pda')) }) + const before = await snapshot(), facts = verifyActual(expected, before.detail, before.customs) + need(rows(before.operations).filter(item => item.actionType === 'COMPLETE_LOADING').length === 1, 'One COMPLETE_LOADING audit required') + data(await request('POST', `${path}/complete`, body, 'pda')) // original key, version and body + const after = await snapshot() + assert.equal(stableSnapshot(after), stableSnapshot(before), 'Exact completion replay has zero projection/version/history drift') + if (spec.kind === 'B') { + need(before.customs.taxRefundCount === 0 && before.customs.status === 'READY_TO_START', 'Actual all-buy readiness') + const start = data(await request('POST', `${CUSTOMS}/${source.loadingPlanId}/start`, + { expectedVersion: before.customs.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('customs-start') })) + need(start.status === 'DECLARING', 'All-buy start') + const reject = await request('POST', `${CUSTOMS}/${source.loadingPlanId}/send-to-broker`, + { expectedVersion: start.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('refund-send-negative') }) + need(reject.http === 200 && String(reject.body?.code) === 'A0409', 'All-buy must reject refund send') + const unchanged = data(await request('GET', `${CUSTOMS}/${source.loadingPlanId}`)) + assert.equal(stableSnapshot(unchanged), stableSnapshot(start), 'Rejected refund send leaves customs unchanged') + const result = data(await request('POST', `${CUSTOMS}/${source.loadingPlanId}/complete-all-buy`, + { expectedVersion: unchanged.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('customs-complete') })) + need(result.status === 'COMPLETED', 'All-buy completion') + } + return { kind: spec.kind, ...facts, source, actions: spec.actions, allActualRowsPartial: expected.filter(item => item.actual > 0).every(item => item.action === 'PARTIAL'), + completed: true, completionReplayZeroDrift: true } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + const options = executionOptions(args, execArgs, env) + if (!options.execute) return plan() + const directory = resolve(ROOT, 'reports', `partial-loading-actual-${options.runId}`) + const bOnlyMode = typeof options.fixture === 'object' && options.fixture.bOnlyReport + const verifyAMode = typeof options.fixture === 'object' && options.fixture.verifyAReport + const completionMode = typeof options.fixture === 'object' && (options.fixture.completionReport || options.fixture.verifyAReport) + const preparedMode = typeof options.fixture === 'object' && options.fixture.preparedReport + const resumeMode = typeof options.fixture === 'object' && options.fixture.resumeReport + const fresh = options.fixture === 'NEW' || resumeMode + let resume + if (resumeMode) { + const originalBytes = await readFile(options.fixture.resumeReport) + const readbackPath = resolve(dirname(options.fixture.resumeReport), 'blocked-readback.json') + const readbackBytes = await readFile(readbackPath), readback = JSON.parse(readbackBytes) + const original = JSON.parse(originalBytes) + resume = { owner: validateResumeProof(original, readback), readback, + originalReport: options.fixture.resumeReport, originalSha256: sha(originalBytes), readbackSha256: sha(readbackBytes) } + const attemptedPath = resolve(ROOT, 'reports/partial-loading-actual-PARTIAL-ACTUAL-20261004-recovery-r1/report.json') + let attemptedBytes + try { attemptedBytes = await readFile(attemptedPath) } catch (error) { if (error.code !== 'ENOENT') throw error } + if (attemptedBytes) { + const attempted = JSON.parse(attemptedBytes) + const mutations = rows(attempted.requests).filter(item => item.method !== 'GET' && !item.path.startsWith('/api/auth/') && !item.path.includes('carton-label-pdf')) + need(attempted.success === false && attempted.recovery?.originalSha256 === resume.originalSha256 + && mutations.length === 1 && mutations[0].path === `${LEG}/inbounds/${resume.owner.inboundId}/measurement/carton-scans?tenantId=${resume.owner.tenantId}` + && mutations[0].http === 200 && mutations[0].code === '200' && mutations[0].response.data.created === false + && mutations[0].response.data.measurementVersion === readback.measurement.version + 1, + 'Prior recovery must prove its sole accepted original re-scan; no blind retry') + resume.readback = structuredClone(readback) + resume.readback.measurement.version = mutations[0].response.data.measurementVersion + resume.readback.measurement.measurementStatus = 'RECEIVED' // the proven scan changed this draft into RECEIVED + resume.priorRecovery = { report: attemptedPath, sha256: sha(attemptedBytes), + knownOriginalRescanRequestId: mutations[0].requestId, expectedMeasurementVersion: resume.readback.measurement.version, + expectedMeasurementStatus: 'RECEIVED' } + } + } + const manifestBytes = bOnlyMode ? await readFile(options.fixture.bOnlyReport) : completionMode ? await readFile(options.fixture.completionReport || options.fixture.verifyAReport) : preparedMode ? await readFile(options.fixture.preparedReport) : fresh ? Buffer.from(resumeMode ? 'RESUME' : 'NEW') : await readFile(options.fixture) + const manifest = fresh || preparedMode || completionMode || bOnlyMode ? null : validateManifest(JSON.parse(manifestBytes), options.runId) + const sources = [], specs = [] + let original + let originalBefore + let aEventProof + if (bOnlyMode) { + const eventProofBytes = await readFile(resolve(ROOT, 'reports/partial-A-completion-event-proof-20261004-r1.json')) + const eventProof = JSON.parse(eventProofBytes) + need(eventProof.readOnly === true && eventProof.verified === true && Object.values(eventProof.checks).every(value => value === true) + && rows(eventProof.rows).length === 2 && rows(eventProof.rows).every(row => row.status === 'PROCESSED' && row.eventVersion === 3), + 'Controller exact A Outbox/ACK/Inbox proof required before B') + aEventProof = { report: 'reports/partial-A-completion-event-proof-20261004-r1.json', sha256: sha(eventProofBytes), checks: eventProof.checks, events: eventProof.rows } + let evidence = JSON.parse(manifestBytes) + need(evidence.success === true && evidence.cases?.length === 1 && evidence.cases[0].kind === 'A' + && evidence.cases[0].originalCommandId === '2106716718935052290' && evidence.cases[0].actualPackages === 3 + && evidence.cases[0].oldOwnersAndDomesticAllocationSnapshotsUnchanged === true && evidence.cases[0].wholeSkippedOwnerNotLoaded === true, + 'B requires the verified original A recovery first') + for (let index = 0; index < 2; index++) { + const parent = await readFile(evidence.completionRecovery.originalReport) + need(sha(parent) === evidence.completionRecovery.originalSha256, 'A evidence chain SHA changed') + evidence = JSON.parse(parent) + } + originalCompletionCommand(evidence) + const preparationBytes = await readFile(evidence.preparedRecovery.report) + need(sha(preparationBytes) === evidence.preparedRecovery.sha256, 'Original prepared B SHA changed') + const preparation = JSON.parse(preparationBytes), checkpoint = validatePreparedResume(preparation)[1] + const taskPath = `${PDA}/tasks/${checkpoint.source.pdaTaskId}?warehouseId=${checkpoint.source.warehouseId}` + const preloadPath = `${LEG}/loading-plans/${checkpoint.source.loadingPlanId}?warehouseId=${checkpoint.source.warehouseId}` + const initialTask = rows(preparation.requests).filter(row => row.path === taskPath && row.method === 'GET').at(-1).response.data + const initialPreload = rows(preparation.requests).filter(row => row.path === preloadPath && row.method === 'GET').at(-1).response.data + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + specs.push({ ...checkpoint, expected: expectedRows(checkpoint.spec, initialTask, initialPreload), + images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }); sources.push(checkpoint.source) + } + if (completionMode) { + let failed = JSON.parse(manifestBytes) + if (verifyAMode) { + originalBefore = rows(failed.checkpoints).find(row => row.stage === 'A_BEFORE_ORIGINAL_RECOVERY')?.before + need(originalBefore && failed.completionRecovery?.originalReport, 'Original recovery before snapshot required') + const originalBytes = await readFile(failed.completionRecovery.originalReport) + need(sha(originalBytes) === failed.completionRecovery.originalSha256, 'Original failed report SHA changed') + failed = JSON.parse(originalBytes) + } + original = originalCompletionCommand(failed) + const preparationBytes = await readFile(failed.preparedRecovery.report) + need(sha(preparationBytes) === failed.preparedRecovery.sha256, 'Original preparation report SHA changed') + const preparation = JSON.parse(preparationBytes) + for (const checkpoint of validatePreparedResume(preparation)) { + const taskPath = `${PDA}/tasks/${checkpoint.source.pdaTaskId}?warehouseId=${checkpoint.source.warehouseId}` + const preloadPath = `${LEG}/loading-plans/${checkpoint.source.loadingPlanId}?warehouseId=${checkpoint.source.warehouseId}` + const initialTask = rows(preparation.requests).filter(row => row.path === taskPath && row.method === 'GET').at(-1)?.response?.data + const initialPreload = rows(preparation.requests).filter(row => row.path === preloadPath && row.method === 'GET').at(-1)?.response?.data + specs.push({ ...checkpoint, expected: expectedRows(checkpoint.spec, initialTask, initialPreload) }); sources.push(checkpoint.source) + } + } + if (preparedMode) { + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + for (const checkpoint of validatePreparedResume(JSON.parse(manifestBytes))) { + specs.push({ ...checkpoint, images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }) + sources.push(checkpoint.source) + } + } + for (const spec of manifest?.cases || []) { + const sourceBytes = await readFile(resolve(dirname(options.fixture), spec.sourceReport)) + const source = fixtureSource(JSON.parse(sourceBytes), options.runId, options.gateway) + const images = {} + for (const type of TYPES) { + const bytes = await readFile(resolve(dirname(options.fixture), spec.evidence[type])) + const png = bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) + const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 + need(bytes.length > 16 && bytes.length <= 20 * 1024 * 1024 && (png || jpeg), 'Evidence must be supplied bounded PNG/JPEG bytes') + images[type] = { bytes, type: png ? 'image/png' : 'image/jpeg', sha256: sha(bytes) } + } + sources.push(source); specs.push({ spec, source, sourceSha256: sha(sourceBytes), images }) + } + if (!fresh && !bOnlyMode) { + for (const key of ['pdaTaskId', 'loadingPlanId', 'containerBookingId']) need(new Set(sources.map(value => value[key])).size === 2, 'Two independent new cabinet identities required') + need(!sources[0].taskItems.some(a => sources[1].taskItems.some(b => id(a.inboundId) === id(b.inboundId))), 'Cases cannot share source inbound resources') + } + await mkdir(directory) // exclusive; never overwrite evidence or silently rerun business writes + const report = { success: false, runId: options.runId, startedAt: new Date().toISOString(), manifestSha256: sha(manifestBytes), requests: [], cases: [], sessions: {}, + ...(bOnlyMode ? { BContinuation: { verifiedAReport: options.fixture.bOnlyReport, verifiedASha256: sha(manifestBytes), readyCommit: options.readyCommit, aEventProof }, syntheticHttpEvidence: true } : {}), + ...(completionMode ? { completionRecovery: { originalReport: options.fixture.completionReport || options.fixture.verifyAReport, originalSha256: sha(manifestBytes), original, readyCommit: options.readyCommit, BDeferred: true, readOnly: Boolean(verifyAMode) } } : {}), + ...(preparedMode ? { preparedRecovery: { report: options.fixture.preparedReport, sha256: sha(manifestBytes) }, syntheticHttpEvidence: true } : {}), + ...(resume ? { recovery: { originalReport: resume.originalReport, originalSha256: resume.originalSha256, + readbackSha256: resume.readbackSha256, originalOwner: resume.owner, originalCreationSkipped: true, priorRecovery: resume.priorRecovery } } : {}), + scope: completionMode ? ['direct Outbox/ACK audit', 'raw Warehouse SQL audit', 'browser/device acceptance', 'B deferred in this dispatch'] : plan().excluded, + fixtures: specs.map(({ spec, source, sourceSha256, images }) => ({ kind: spec.kind, source, sourceSha256, + ...(images ? { imageHashes: Object.fromEntries(TYPES.map(type => [type, images[type].sha256])) } : {}) })) } + const sessions = {}, save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(redact(report), null, 2)) + const nativeFetch = globalThis.fetch + let requestQueue = Promise.resolve(), transportUnknown = false + const serialFetch = (url, init) => { + const pending = requestQueue.then(() => { need(!transportUnknown || init.method === 'GET' || new URL(url).pathname === '/api/auth/logout', 'Earlier unknown transport result; stop new commands and preserve samples'); return nativeFetch(url, init) }) + requestQueue = pending.then(() => {}, () => { transportUnknown = true }) + return pending + } + const recordSession = (payload, role) => { + if (payload?.data?.accessToken) { + const already = Object.entries(sessions).find(([, value]) => value.token === payload.data.accessToken) + const name = already?.[0] || (role === 'admin' || role === 'pda' ? role : `customer-${Object.keys(sessions).length}`) + sessions[name] = { token: payload.data.accessToken, refresh: payload.data.refreshToken } + } + } + async function request(method, path, body, role = 'admin', headers = {}) { + need(!verifyAMode || method === 'GET' || ['/api/auth/login','/api/auth/logout'].includes(path), 'A post-recovery verification is read-only') + const auth = path.startsWith('/api/auth/') + const entry = { method, path, role, intendedAt: new Date().toISOString(), state: 'PENDING', + ...(auth ? {} : body instanceof FormData ? { multipart: true } : body === undefined ? {} : { request: body }), + idempotencyKey: headers['Idempotency-Key'] } + if (body instanceof FormData) { + entry.multipartParts = [] + for (const [name, value] of body.entries()) entry.multipartParts.push(typeof value === 'string' ? { name, value } + : { name, fileName: value.name, size: value.size, type: value.type, sha256: sha(new Uint8Array(await value.arrayBuffer())) }) + } + report.requests.push(entry); await save() + try { + const response = await serialFetch(options.gateway + path, requestInit(method, body, sessions[role]?.token, headers)) + const payload = await response.json() + if (path === '/api/auth/login') recordSession(payload, role) + Object.assign(entry, { state: 'RECEIVED', http: response.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId, + ...(auth ? {} : { response: payload }) }); await save() + return { method, path, http: response.status, body: payload, requestId: payload.requestId, traceId: payload.traceId } + } catch { entry.state = 'UNKNOWN'; await save(); throw new CheckFailure(`Transport/response failed: ${method} ${path}; preserve original request, do not auto-retry`) } + } + try { + for (const [role, username, password] of [['admin', env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD], ['pda', env.ADMIN_PDA_USERNAME, env.ADMIN_PDA_PASSWORD]]) { + const response = await request('POST', '/api/auth/login', { loginId: username, password }, role) + const data = response.body?.data + if (data?.accessToken) sessions[role] = { token: data.accessToken, refresh: data.refreshToken } + need(response.http === 200 && String(response.body?.code) === '200' && data?.accessToken && data?.refreshToken, 'Incomplete own login session') + report.sessions[role] = { loggedIn: true, closed: false } + } + if (fresh) { + // Reused producers have a few raw binary/multipart fetches. Instrument them too, + // serialize through the same queue, persist intent first, and never expose credentials. + globalThis.fetch = async (url, init = {}) => { + const parsed = new URL(url); need(parsed.origin === options.gateway, 'Producer network must stay in TEST Gateway') + const entry = { method: init.method || 'GET', path: parsed.pathname + parsed.search, producerRaw: true, intendedAt: new Date().toISOString(), state: 'PENDING' } + const form = init.body instanceof FormData + if (form) { + entry.multipart = [] + for (const [name, value] of init.body.entries()) entry.multipart.push(typeof value === 'string' + ? { name, value } : { name, size: value.size, type: value.type, sha256: sha(new Uint8Array(await value.arrayBuffer())) }) + } else if (init.body) { try { entry.request = redact(JSON.parse(init.body)) } catch { entry.requestSha256 = sha(String(init.body)) } } + report.requests.push(entry); await save() + let response + try { response = await serialFetch(url, { ...init, redirect: 'error' }) } + catch { entry.state = 'UNKNOWN'; await save(); throw new CheckFailure(`Producer transport unknown: ${entry.method} ${entry.path}`) } + const bytes = new Uint8Array(await response.clone().arrayBuffer()) + entry.http = response.status; entry.state = 'RECEIVED' + if ((response.headers.get('content-type') || '').includes('json')) { + const payload = JSON.parse(new TextDecoder().decode(bytes)); entry.code = payload.code + entry.requestId = payload.requestId; entry.traceId = payload.traceId; entry.response = redact(payload) + if (parsed.pathname === '/api/auth/login') recordSession(payload, 'producer') + } else { entry.size = bytes.length; entry.sha256 = sha(bytes) } + await save(); return response + } + const { AcceptanceRun, parseArguments } = await import('./admin-test-env-acceptance.mjs') + const context = new AcceptanceRun({ ...parseArguments(['--modules','orders,inbound-measurement','--quiet','true']), gateway: options.gateway, + username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, customerName: '', waitSplitCount: 1, + routeMode: 'fixed', orderProfileMode: 'fixed' }) + context.token = sessions.admin.token; context.pdaToken = sessions.pda.token + const originalStep = context.addStep.bind(context) + context.addStep = (...args) => { const passed = originalStep(...args); if (!passed) throw new CheckFailure(`Fresh producer gate failed: ${args[0]} / ${args[1]}`); return passed } + const roleFor = token => token ? Object.entries(sessions).find(([, value]) => value.token === token)?.[0] || 'admin' : 'producer' + context.request = (method, path, body, token = context.token, headers = {}) => { + need(method === 'GET' || !/^\/api\/platform\/v1\/(suppliers|business-partners|ocean-bookings\/channels)/.test(path), 'Shared booking master data writes require controller coordination') + return request(method, path, body, roleFor(token), headers) + } + const adapter = multipartAdapter(request, roleFor) + context.requestMultipart = (method, path, file, token = context.token, headers = {}) => adapter(method, path, file, token, headers) + const freshCases = await prepareFreshCases(options.runId, context, async checkpoint => { + report.preparation ||= []; report.preparation.push(checkpoint); await save() + }, resume) + globalThis.fetch = nativeFetch + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + report.syntheticHttpEvidence = true // controlled HTTP fixture; never camera/device proof + for (const value of freshCases) specs.push({ ...value, images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }) + report.fixtures = specs.map(({ spec, source, owners }) => ({ kind: spec.kind, source, owners })) + await save() + } + const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Preflight API rejected ${response.path}`); return response.body.data } + const ownPdaId = JSON.parse(Buffer.from(sessions.pda.token.split('.')[1], 'base64url').toString()).sub + need(positive(ownPdaId), 'Own PDA subject required') + // Both cabinets are checked before the first claim/batch/evidence/complete write. + if (completionMode) { + const checkpoint = async snapshot => { + report.checkpoints ||= []; report.checkpoints.push(snapshot); await save() + } + report.cases.push(verifyAMode ? await verifyRecoveredA(specs[0], original, originalBefore, request, checkpoint) + : await recoverOriginalA(specs[0], original, request, checkpoint)) + report.success = true // this dispatch is A-only; B is explicitly deferred and untouched + } else { + for (const item of specs) { + const { source, spec } = item, path = `${PDA}/tasks/${source.pdaTaskId}` + const task = ok(await request('GET', `${path}?warehouseId=${source.warehouseId}`, undefined, 'pda')) + validateZeroWorkTask(source, task, ownPdaId) + const originalIds = source.taskItems.map(row => id(row.id)).sort() + assert.deepEqual(task.items.map(row => id(row.id)).sort(), originalIds, 'Source fixture item set') + const preload = ok(await request('GET', `${LEG}/loading-plans/${source.loadingPlanId}?warehouseId=${source.warehouseId}`)) + need(['EDITING', 'LOADING'].includes(preload.summary.status), 'Zero-pick preload must be EDITING or own claimed LOADING') + const operations = ok(await request('GET', `${path}/operations`, undefined, 'pda')) + need(!rows(operations).some(row => ['BATCH', 'COMPLETE_LOADING'].includes(row.actionType)) + && rows(operations).filter(row => row.actionType === 'CLAIM').every(row => row.operationId === `${source.sourceMarker}-claim`), + 'No earlier physical picking/completion or foreign claim allowed') + const evidence = ok(await request('GET', `${path}/evidence?warehouseId=${source.warehouseId}`, undefined, 'pda')) + need(!rows(evidence.evidences).some(row => row.status === 'ACTIVE'), 'New task must have empty active evidence baseline') + item.task = task; item.expected = expectedRows(spec, task, preload) + } + for (const item of specs) { + const result = await runCase(item.spec, item.source, item, request) + if (bOnlyMode) { + const downstream = await waitDownstream(item, request, ms => new Promise(done => setTimeout(done, ms))) + result.downstream = downstream.facts; report.BAfter = { source: await sourceSnapshot(item, request), downstream } + } + report.cases.push(result); await save() + } + report.success = report.cases.length === (bOnlyMode ? 1 : 2) + } + } catch (error) { + report.failure = error instanceof CheckFailure || error instanceof assert.AssertionError ? error.message : 'Local response/fixture format failure' + } finally { + globalThis.fetch = nativeFetch + // A transport-unknown business command stops all further business writes; + // own session logout is still allowed and does not replay that command. + transportUnknown = false + for (const [role, session] of Object.entries(sessions).reverse()) { + try { + const response = await request('POST', '/api/auth/logout', { refreshToken: session.refresh }, role) + const closed = response.http === 200 && String(response.body?.code) === '200' + report.sessions[role] = { loggedIn: true, closed }; if (!closed) report.success = false + } catch { report.sessions[role] = { loggedIn: true, closed: false }; report.success = false } + } + report.finishedAt = new Date().toISOString(); await save() + } + return { success: report.success, report: resolve(directory, 'report.json') } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Partial-loading acceptance did not start; check TEST env-file, new run-id and legal fixture manifest.'); process.exitCode = 1 }) +} diff --git a/scripts/partial-loading-actual-test-env.test.mjs b/scripts/partial-loading-actual-test-env.test.mjs new file mode 100644 index 000000000..c8ddc93ca --- /dev/null +++ b/scripts/partial-loading-actual-test-env.test.mjs @@ -0,0 +1,349 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { plan, execute, executionOptions, validateManifest, fixtureSource, expectedRows, scale, verifyActual, runCase, redact, + multipartAdapter, requestInit, validateResumeProof, validateResumeLive, resumeMeasurementDraft, validateZeroWorkTask, validatePreparedResume, + originalCompletionCommand, verifyPhysicalFacts, verifyOldOwnersUnchanged, verifyDownstream } from './partial-loading-actual-test-env.mjs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { createServer } from 'node:http' + +const runId = 'PARTIAL-ACTUAL-offline' +const evidence = Object.fromEntries(['PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL'].map(type => [type, 'sample.png'])) +function fixture(kind = 'B') { + const definitions = kind === 'B' ? [[1, 11, 2, 'BUY_DECLARATION'], [2, 22, 1, 'TAX_REFUND']] + : [[1, 11, 3, 'TAX_REFUND'], [2, 11, 1, 'TAX_REFUND'], [3, 22, 1, 'BUY_DECLARATION'], [4, 33, 1, 'TAX_REFUND']] + const task = { id: '91', loadingPlanId: '90', containerBookingId: '92', version: 0, + items: definitions.map(([n, tenant, count]) => ({ id: String(n), loadingPlanItemId: String(n + 100), tenantId: String(tenant), + inboundId: String(kind === 'A' && n === 2 ? 1 : n), inboundDetailId: String(n + 200), plannedPackageCount: count, + pickedPackageCount: 0, itemStatus: 'PENDING', unitType: 'PACKAGE', palletId: null, version: 0 })) } + const preload = { items: definitions.map(([n, tenant, count, tax]) => ({ itemId: String(n + 100), accountId: String(tenant), + inboundId: String(kind === 'A' && n === 2 ? 1 : n), inboundDetailId: String(n + 200), customsTaxMode: tax, orderId: String(n + 300), + packageCount: count, productQuantity: count * 7, unitsPerPackage: 7, grossWeightKg: '10.001', netWeightKg: '8.002', dimensionalWeightKg: '6.004', volumeCbm: '0.1001' })) } + const spec = { kind, sourceReport: `${kind}.json`, evidence, sealNo: 'SEAL-TEST', actions: definitions.map(([n]) => ({ itemId: String(n), + action: n === 1 ? 'PARTIAL' : kind === 'A' && n === 3 ? 'PICK' : 'SKIP', ...(n === 1 ? { finalPickedPackageCount: kind === 'A' ? 2 : 1 } : {}) })) } + const expected = expectedRows(spec, task, preload) + const actualItems = expected.filter(row => row.actual > 0).map(row => ({ ...row.frozen, packageCount: row.actual, + productQuantity: row.actual * 7, ...Object.fromEntries(['grossWeightKg','netWeightKg','dimensionalWeightKg','volumeCbm'].map(field => + [field, scale(row.frozen[field], row.actual, row.planned, field === 'volumeCbm' ? 4 : 3)])) })) + const sum = (field, digits) => Number(actualItems.reduce((value, item) => value + BigInt(scale(item[field], 1, 1, digits).replace('.', '')), 0n)) / 10 ** digits + const detail = { items: actualItems, summary: { totalPackageCount: actualItems.reduce((sum, row) => sum + row.packageCount, 0), + totalQuantity: actualItems.reduce((sum, row) => sum + row.productQuantity, 0), totalGrossWeightKg: sum('grossWeightKg', 3), + totalNetWeightKg: sum('netWeightKg', 3), totalVolumeCbm: sum('volumeCbm', 4) } } + const customs = { actualBoxCount: detail.summary.totalPackageCount, inbounds: expected.filter(row => row.actual > 0), + taxRefundCount: kind === 'B' ? 0 : 1, version: 0, status: kind === 'B' ? 'READY_TO_START' : 'WAITING_CUSTOMER_DOCUMENTS' } + const source = { pdaTaskId: '91', loadingPlanId: '90', containerBookingId: '92', warehouseId: '1', sourceMarker: `${runId}-${kind}` } + return { task, preload, spec, expected, detail, customs, source } +} + +test('default plan and --plan make zero network calls', async () => { + const original = globalThis.fetch + globalThis.fetch = () => { throw Error('No network in plan') } + try { assert.deepEqual(await execute([], [], {}), plan()); assert.equal((await execute(['--plan'], [], {})).networkRequests, 0) } + finally { globalThis.fetch = original } +}) + +test('execution refuses absent explicit TEST/profile/cwd', () => { + assert.throws(() => executionOptions(['--execute'], [], {})) + assert.throws(() => executionOptions(['--execute','--environment=TEST',`--run-id=${runId}`,'--fixture=x.json'], [], {}, 'C:/other')) +}) + +test('NEW mode uses the configured /api base and does not accept a bare origin', () => { + const cwd = fileURLToPath(new URL('..', import.meta.url)) + const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid/api', + CHONGCHONG_ADMIN_USERNAME: 'admin', CHONGCHONG_ADMIN_PASSWORD: 'test-only', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'test-only' } + const args = ['--execute','--environment=TEST',`--run-id=${runId}`,'--fixture=NEW'] + const execArgs = [`--env-file=${resolve('/.codex/env/chongchong-global/test-acceptance.env')}`] + assert.equal(executionOptions(args, execArgs, env, cwd).fixture, 'NEW') + assert.equal(executionOptions(args, execArgs, env, cwd).gateway, 'https://test.invalid') + assert.throws(() => executionOptions(args, execArgs, { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid' }, cwd), /end in/) +}) + +test('nested generated customer passwords and login/refresh tokens never reach saved evidence', () => { + const value = redact({ requests: [{ response: { data: { temporaryPassword: 'SECRET', nested: { accessToken: 'TOKEN', refreshToken: 'REFRESH' }, tenantId: '11' } } }] }) + const json = JSON.stringify(value) + assert.ok(!json.includes('SECRET') && !json.includes('TOKEN') && !json.includes('REFRESH')) + assert.ok(json.includes('"tenantId":"11"')) +}) + +test('real HTTP multipart adapter preserves descriptor bytes, existing form fields and generated boundary', async () => { + const received = [], original = Uint8Array.from([137,80,78,71,13,10,26,10,0,255,10,13,0,200]) + const server = createServer(async (request, response) => { + const chunks = []; for await (const chunk of request) chunks.push(chunk) + received.push({ headers: request.headers, body: Buffer.concat(chunks), method: request.method }) + response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"code":"200"}') + }) + await new Promise(done => server.listen(0, '127.0.0.1', done)) + try { + const base = `http://127.0.0.1:${server.address().port}` + const adapter = multipartAdapter((method, path, body, role, headers) => { + assert.equal(role, 'admin'); return fetch(base + path, requestInit(method, body, 'test-token', headers)) + }, () => 'admin') + await adapter('POST', '/upload', { content: original, type: 'image/png', name: 'weight.png' }, 'token') + const form = new FormData(); form.append('expectedVersion', '4'); form.append('file', new Blob([original], { type: 'image/png' }), 'original.png') + await adapter('POST', '/upload', form, 'token', { 'Content-Type': 'application/json', 'Idempotency-Key': 'same-key' }) + assert.equal(received.length, 2) + for (const request of received) { + assert.match(request.headers['content-type'], /^multipart\/form-data; boundary=/) + const boundary = request.headers['content-type'].split('boundary=')[1] + assert.ok(request.body.subarray(0, boundary.length + 2).equals(Buffer.from(`--${boundary}`))) + assert.ok(request.body.includes(Buffer.from(original)), 'The original byte array reaches HTTP without JSON conversion') + assert.ok(request.body.includes(Buffer.from('name="file"'))) + assert.ok(!request.body.includes(Buffer.from('name="content"'))) + assert.equal(request.method, 'POST') + } + assert.ok(received[1].body.includes(Buffer.from('name="expectedVersion"\r\n\r\n4'))) + assert.equal(received[1].headers['idempotency-key'], 'same-key') + } finally { server.closeAllConnections(); await new Promise(done => server.close(done)) } +}) + +test('explicit resume proves the original failed sample and refuses state/identity drift before writes', () => { + const summary = { id: '2106706513799155714', tenantId: '2106706477225971713', orderId: '2106706495895306242', + orderNo: 'FL2106706495895306242', inboundCode: 'NYEXX2610040025', processStatus: 'WAIT_MEASURE', version: 4 } + const measurement = { measurementId: '2106706548955811842', measurementStatus: 'DRAFT', version: 4, weightResults: [] } + const readback = { verified: true, sessionClosed: true, inbound: { summary }, measurement } + const report = { runId: 'PARTIAL-ACTUAL-20261004-r1', success: false, cases: [], requests: [{ path: '/api/platform/v1/files/measurements/2106706477225971713/2106706513799155714', http: 200, code: 'S0500', requestId: 'original-id' }] } + const owner = validateResumeProof(report, readback) + validateResumeLive(owner, readback, readback.inbound, measurement) + assert.throws(() => validateResumeProof({ ...report, runId: 'unrelated' }, readback), /explicitly authorized/) + assert.throws(() => validateResumeLive(owner, readback, { summary: { ...summary, version: 5 } }, measurement), /changed/) + assert.throws(() => validateResumeLive(owner, readback, readback.inbound, { ...measurement, weightResults: [{ id: '1' }] }), /changed/) + assert.throws(() => validateResumeProof(report, { ...readback, measurement: { ...measurement, measurementStatus: 'APPROVED' } }), /Frozen/) +}) + +test('original draft resumes by upload/measurement only, with independent 3+1 SKU rows', async () => { + const owner = { tenantId: '11', inboundId: '12', orderId: '13', marker: 'ORIGINAL' } + const initial = { measurementId: '14', version: 5, latestDeclaration: { items: [ + { inboundDetailId: '21', itemNo: 1, cargoName: 'SKU1', packageCount: 3, lengthCm: 30, widthCm: 20, heightCm: 20 }, + { inboundDetailId: '22', itemNo: 2, cargoName: 'SKU2', packageCount: 1, lengthCm: 40, widthCm: 30, heightCm: 30 }] } } + const calls = []; let approved = false + const context = { token: 'own-token', createdResources: {}, request: async (method, path, body) => { + calls.push({ method, path, body }); let data + if (path.includes('/files/measurements/')) data = { fileId: '41' } + else if (path.includes('/measurement/draft?')) data = { measurementId: '14' } + else if (path.includes('/weight-pallet-options?')) data = { records: [{ palletId: '51', palletCode: 'YW-TP-002' }] } + else if (path.includes('/weight-results/61/confirm?')) data = { recognitionStatus: 'CONFIRMED' } + else if (path.includes('/weight-results?')) data = { id: '61', version: 0 } + else if (path.includes('/measurement/submit?')) data = { processStatus: 'MEASURE_PENDING' } + else if (path.includes('/measurements/approve?')) { approved = true; data = {} } + else if (path.includes('/measurement?')) data = { measurementId: '14', version: 6, measurementStatus: approved ? 'APPROVED' : 'DRAFT', + processStatus: approved ? 'WAIT_SPLIT' : 'WAIT_MEASURE', items: initial.latestDeclaration.items } + else if (path.startsWith('/api/platform/inbounds/')) data = { summary: { version: 7 } } + else throw Error('Unexpected original-recovery endpoint') + return { path, http: 200, body: { code: '200', data } } + } } + context.requestMultipart = multipartAdapter(context.request, () => 'admin') + await resumeMeasurementDraft(context, owner, initial) + assert.equal(context.createdResources.inboundMeasurement.inboundId, '12') + assert.equal(context.createdResources.inboundMeasurement.processStatus, 'WAIT_SPLIT') + assert.ok(calls[0].body instanceof FormData) + assert.deepEqual(calls.find(call => call.path.includes('/measurement/draft?')).body.items.map(item => [item.inboundDetailId, item.packageCount]), [['21',3],['22',1]]) + assert.ok(!calls.some(call => /carton-scans|\/iam\/tenants|\/orders/.test(call.path)), 'No qualification, creation, order submission or re-scan') +}) + +test('zero-work checkpoint accepts own preassignment but rejects another operator or any physical pick', () => { + const { source, task } = fixture() + task.taskStatus = 'LOADING'; task.workflowStatus = 'IN_PROGRESS'; task.assignment = { assigneeId: '99' } + source.beforeTaskVersion = 0 + validateZeroWorkTask(source, task, '99') + assert.throws(() => validateZeroWorkTask(source, task, '98'), /own PDA/) + task.items[0].pickedPackageCount = 1 + assert.throws(() => validateZeroWorkTask(source, task, '99'), /physical work/) +}) + +test('prepared continuation binds the two original-source checkpoints and refuses to redo PDA writes', () => { + const report = { runId: 'PARTIAL-ACTUAL-20261004-recovery-r3', success: false, cases: [], requests: [], + recovery: { originalOwner: { inboundId: '2106706513799155714' }, originalSha256: 'frozen-original-hash' }, + preparation: ['A','B'].map(kind => ({ kind, stage: 'PDA_UNASSIGNED', source: { sourceMarker: `PARTIAL-ACTUAL-20261004-recovery-r3-${kind}` }, spec: { kind } })) } + assert.equal(validatePreparedResume(report).length, 2) + assert.throws(() => validatePreparedResume({ ...report, requests: [{ method: 'POST', path: '/api/platform/v1/first-leg/pda-loading/tasks/91/batch' }] }), /repeat any PDA/) + assert.throws(() => validatePreparedResume({ ...report, cases: [{ completed: true }] }), /Only this/) +}) + +test('A recovery takes only the original command id and immutable version/operation/remark', () => { + const body = { expectedTaskVersion: 2, operationId: 'PARTIAL-ACTUAL-20261004-recovery-r3-A-complete', remark: 'PARTIAL-ACTUAL-20261004-recovery-r3-A' } + const report = { runId: 'PARTIAL-ACTUAL-20261004-recovery-r7', success: false, requests: [{ method: 'POST', + path: '/api/platform/v1/first-leg/pda-loading/tasks/2106713892758491138/complete', state: 'RECEIVED', http: 200, code: '200', request: body, + response: { data: { completionCommand: { id: '2106716718935052290', operationId: body.operationId, expectedTaskVersion: 2 } } } }] } + assert.deepEqual(originalCompletionCommand(report), { commandId: '2106716718935052290', body }) + const changed = structuredClone(report); changed.requests[0].request.expectedTaskVersion = 4 + assert.throws(() => originalCompletionCommand(changed), /immutable/) + const duplicated = structuredClone(report); duplicated.requests.push(duplicated.requests[0]) + assert.throws(() => originalCompletionCommand(duplicated), /Exactly one/) +}) + +test('physical A facts exclude skipped rows and unchanged owners/stock detect a repeated completion or deduction', () => { + const sample = fixture('A') + sample.task.items = sample.task.items.map(row => ({ ...row, itemStatus: { PARTIAL: 'PARTIAL', PICK: 'PICKED', SKIP: 'SKIPPED' }[sample.expected.find(item => item.pickId === row.id).action], + pickedPackageCount: sample.expected.find(item => item.pickId === row.id).actual })) + verifyPhysicalFacts(sample.expected, sample.task) + const drift = structuredClone(sample.task); drift.items[0].pickedPackageCount++ + assert.throws(() => verifyPhysicalFacts(sample.expected, drift), /drift/) + const before = { sources: [{ owner: { inboundId: '1' }, view: { summary: { id: '1', processStatus: 'LOADED', version: 12 }, statusHistory: [{ action: 'COMPLETE_LOADING' }], palletLocations: [] } }], inventory: { inbounds: [] } } + verifyOldOwnersUnchanged(before, structuredClone(before), sample.expected) + const repeated = structuredClone(before); repeated.sources[0].view.statusHistory.push({ action: 'COMPLETE_LOADING' }) + assert.throws(() => verifyOldOwnersUnchanged(before, repeated, sample.expected), /must not repeat/) + const deducted = structuredClone(before); deducted.inventory.inbounds.push({ availablePackageCount: 1 }) + assert.throws(() => verifyOldOwnersUnchanged(before, deducted, sample.expected), /deducted again/) +}) + +test('transit evidence uses raw balance identity and excludes transit from physical total, with no skipped source or duplicate ledger', () => { + const sample = fixture('A'); sample.source.containerNo = 'TEST-CONTAINER' + for (const row of sample.expected) { row.frozen.inboundCode = `IN-${row.inboundId}`; row.frozen.skuCode = `SKU-${row.itemId}` } + const snapshot = ['1','3','4'].map(inboundId => { + const active = sample.expected.filter(row => row.inboundId === inboundId && row.actual > 0), ownerRow = sample.expected.find(row => row.inboundId === inboundId) + const owner = { inboundId, tenantId: ownerRow.accountId }, count = active.reduce((sum,row) => sum + row.actual, 0) + if (!count) return { owner, orders: [], balances: [], ledger: [], details: [] } + const order = { id: `100${inboundId}`, orderNo: `LL-${inboundId}`, customerId: owner.tenantId, sourceInboundId: inboundId, + sourceContainerBookingId: sample.source.containerBookingId, containerNo: sample.source.containerNo, packageCount: count } + return { owner, orders: [order], details: [{ cargo: active.map(row => ({ skuCode: row.frozen.skuCode, packageCount: row.actual })), + statusHistory: [{ toStatus: 'PENDING_ASSIGNMENT', remark: '头程装柜完成自动生成' }] }], + balances: [{ customerId: owner.tenantId, inboundCode: `IN-${inboundId}`, inboundOrderId: order.id, inTransitPackages: count, + totalPackages: 0, availablePackages: 0, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, procurementPackages: 0 }], + ledger: [{ customerId: owner.tenantId, inboundCode: `IN-${inboundId}`, inboundOrderId: order.id, businessOrderNo: order.orderNo, + beforePackages: 0, deltaPackages: count, afterPackages: count }] } + }) + assert.equal(verifyDownstream(sample, snapshot).actualPackages, 3) + const doubled = structuredClone(snapshot); doubled[0].ledger.push(doubled[0].ledger[0]) + assert.throws(() => verifyDownstream(sample, doubled), /inventory movement/) + const grouped = structuredClone(snapshot); grouped[0].balances[0].inboundOrderId = null + assert.throws(() => verifyDownstream(sample, grouped), /transit inventory/) + const skipped = structuredClone(snapshot); skipped[2].orders.push(snapshot[0].orders[0]) + assert.throws(() => verifyDownstream(sample, skipped), /Skipped owner/) +}) + +test('manifest needs two ordered cases, unique actions and all five photos', () => { + const manifest = { schemaVersion: 1, runId, environment: 'TEST', cases: [fixture('A').spec, fixture('B').spec] } + assert.equal(validateManifest(manifest, runId), manifest) + const duplicate = structuredClone(manifest); duplicate.cases[1].actions[1].itemId = '1' + assert.throws(() => validateManifest(duplicate, runId), /unique/) + const missing = structuredClone(manifest); delete missing.cases[0].evidence.SEAL + assert.throws(() => validateManifest(missing, runId), /Five/) +}) + +test('official source must be fresh, passing, unclaimed and run-bound', () => { + const now = Date.parse('2026-10-04T12:00:00Z'), report = { success: true, fatal: null, summary: { failed: 0 }, gateway: 'https://test.invalid', + createdResources: { manualPdaBatchFixture: { ...fixture().source, schemaVersion: 1, stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', + preparedAt: new Date(now).toISOString(), taskItems: fixture().task.items } } } + assert.ok(fixtureSource(report, runId, report.gateway, now)) + assert.throws(() => fixtureSource(report, runId, report.gateway, now + 25 * 3600_000), /within/) + const old = structuredClone(report); old.createdResources.manualPdaBatchFixture.sourceMarker = 'old' + assert.throws(() => fixtureSource(old, runId, report.gateway, now), /run-bound/) + const failed = structuredClone(report); failed.summary.failed = 1 + assert.throws(() => fixtureSource(failed, runId, report.gateway, now), /did not pass/) +}) + +test('exact HALF_UP handles non-half ratios, ties, nullable metrics and invalid ratios', () => { + assert.equal(scale('10.001', 2, 3, 3), '6.667') + assert.equal(scale('8.002', 2, 3, 3), '5.335') + assert.equal(scale('0.1001', 2, 3, 4), '0.0667') + assert.equal(scale('1', 1, 2, 0), '1') + assert.equal(scale(null, 1, 2, 3), null) + assert.throws(() => scale('10', 4, 3, 3)) +}) + +test('A and B require the exact actual/skip shapes before any mutation', () => { + assert.equal(fixture('A').expected.filter(row => row.actual > 0).length, 2) + assert.ok(fixture('B').expected.filter(row => row.actual > 0).every(row => row.action === 'PARTIAL')) + const { spec, task, preload } = fixture('B') + spec.actions[0] = { itemId: '1', action: 'PICK' } + assert.throws(() => expectedRows(spec, task, preload), /exactly one/) + spec.actions[0] = { itemId: '1', action: 'PARTIAL', finalPickedPackageCount: 2 } + assert.throws(() => expectedRows(spec, task, preload), /less than/) +}) + +test('duplicate, missing, already picked and wrong-customer source rows reject preflight', () => { + const a = fixture('A'); a.spec.actions.pop() + assert.throws(() => expectedRows(a.spec, a.task, a.preload), /cover every/) + const b = fixture(); b.task.items[0].pickedPackageCount = 1 + assert.throws(() => expectedRows(b.spec, b.task, b.preload), /zero-picked/) + const c = fixture(); c.preload.items[0].accountId = '999' + assert.throws(() => expectedRows(c.spec, c.task, c.preload), /identity/) +}) + +test('actual scope, metrics and refund customer/coverage are independently checked', () => { + for (const kind of ['A', 'B']) { + const sample = fixture(kind) + assert.equal(verifyActual(sample.expected, sample.detail, sample.customs).actualRows, kind === 'A' ? 2 : 1) + const contaminated = structuredClone(sample.detail); contaminated.items.push(sample.preload.items.at(-1)) + assert.throws(() => verifyActual(sample.expected, contaminated, sample.customs), /Actual loading row set/) + const stale = structuredClone(sample.customs); stale.actualBoxCount++ + assert.throws(() => verifyActual(sample.expected, sample.detail, stale), /Customs actual boxes/) + const wrong = structuredClone(sample.customs); wrong.inbounds[0].accountId = '999' + assert.throws(() => verifyActual(sample.expected, sample.detail, wrong), /Customs source/) + } +}) + +function transport(sample, { drift = false, failCompletion = false, incomplete = false } = {}) { + const calls = [], task = structuredClone(sample.task), customs = structuredClone(sample.customs) + const history = [], pda = `/api/platform/v1/first-leg/pda-loading/tasks/${sample.source.pdaTaskId}` + let completes = 0 + return { calls, request: async (method, path, body, role, headers) => { + calls.push({ method, path, body: body instanceof FormData ? 'multipart' : structuredClone(body), role, headers }) + let data + if (method === 'POST' && path === `${pda}/claim`) { task.workflowStatus = 'IN_PROGRESS'; data = task } + else if (method === 'POST' && /\/items\/\d+\/skip$/.test(path)) { + const itemId = path.split('/').at(-2) + task.items = task.items.map(row => row.id === itemId ? { ...row, itemStatus: 'SKIPPED', pickedPackageCount: 0 } : row) + data = task + } + else if (method === 'POST' && path === `${pda}/batch`) { + task.workflowStatus = 'AWAITING_REVIEW'; task.items = task.items.map(row => { + const expected = sample.expected.find(item => item.pickId === row.id) + return { ...row, itemStatus: { PICK: 'PICKED', PARTIAL: 'PARTIAL', SKIP: 'SKIPPED' }[expected.action], pickedPackageCount: expected.actual } + }); data = task + } else if (method === 'POST' && path === `${pda}/evidence`) data = {} + else if (path.startsWith(`${pda}/evidence?`)) data = { evidences: Object.keys(evidence).map(type => ({ evidenceType: type, status: 'ACTIVE', sealNo: sample.spec.sealNo })) } + else if (method === 'POST' && path === `${pda}/complete`) { + completes++ + if (failCompletion) throw Error('Response lost') + task.taskStatus = incomplete ? 'COMPLETING' : 'COMPLETED'; task.workflowStatus = incomplete ? 'COMPLETING' : 'COMPLETED' + if (completes === 1) history.push({ actionType: 'COMPLETE_LOADING' }) + if (drift && completes > 1) task.version++ + data = task + } else if (path.startsWith(`${pda}/inventory-snapshot?`)) data = { snapshotToken: 'fresh-snapshot' } + else if (path.startsWith(`${pda}?`)) data = task + else if (path === `${pda}/operations`) data = history + else if (path.includes('/loading-lists/')) data = sample.detail + else if (path.endsWith('/send-to-broker')) return { path, http: 200, body: { code: 'A0409' } } + else if (path.endsWith('/start')) { customs.status = 'DECLARING'; customs.version++; data = customs } + else if (path.endsWith('/complete-all-buy')) { customs.status = 'COMPLETED'; customs.version++; data = customs } + else if (path.includes('/customs/loading-plans/')) data = customs + else throw Error(`Unexpected ${method} ${path}`) + return { path, http: 200, body: { code: '200', data: structuredClone(data) } } + } } +} + +test('both cases execute actual completion; B is all PARTIAL and follows all-buy gates', async () => { + for (const kind of ['A', 'B']) { + const sample = fixture(kind), http = transport(sample), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1,2,3]), type: 'image/png' }])) + const result = await runCase(sample.spec, sample.source, { ...sample, images }, http.request, async () => {}) + assert.equal(result.completed, true); assert.equal(result.allActualRowsPartial, kind === 'B') + const completes = http.calls.filter(call => call.path.endsWith('/complete')) + assert.equal(completes.length, 2); assert.deepEqual(completes[0].body, completes[1].body) + const batches = http.calls.filter(call => call.path.endsWith('/batch')) + assert.equal(batches.length, 1) + if (kind === 'A') { + const skipIndex = http.calls.findIndex(call => /\/items\/\d+\/skip$/.test(call.path)) + assert.ok(skipIndex >= 0 && skipIndex < http.calls.findIndex(call => call.path.endsWith('/batch'))) + assert.ok(!batches[0].body.items.some(item => item.itemId === '2'), 'Resolved same-owner SKIP does not repeat in full pending batch') + } + if (kind === 'B') assert.ok(http.calls.some(call => call.path.endsWith('/complete-all-buy'))) + } +}) + +test('replay version/history drift is a hard failure', async () => { + const sample = fixture(), http = transport(sample, { drift: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request), /zero projection/) +}) + +test('unknown completion result is never retried and no later customs write is attempted', async () => { + const sample = fixture(), http = transport(sample, { failCompletion: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request), /Response lost/) + assert.equal(http.calls.filter(call => call.path.endsWith('/complete')).length, 1) + assert.equal(http.calls.filter(call => call.method === 'POST' && call.path.includes('/customs/')).length, 0) +}) + +test('accepted completion without terminal state fails rather than claiming success', async () => { + const sample = fixture(), http = transport(sample, { incomplete: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request, async () => {}), /polling exhausted/) +}) -- 2.54.0 From b000b056d1d5d938f37cc7dd2eeea846335c16a2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 01:06:11 +0800 Subject: [PATCH 146/176] feat(orders): expose customer-owned loaded transport facts --- .../dto/CustomerTransportFragment.java | 17 ++++ .../firstleg/dto/UnifiedInboundDetail.java | 3 +- .../mapper/UnifiedInboundTransportMapper.java | 23 +++++ .../UnifiedInboundReadRepository.java | 18 ++++ .../service/UnifiedInboundQueryService.java | 5 +- .../main/resources/openapi/firstleg-v1.yaml | 24 +++++ .../UnifiedInboundOpenApiContractTest.java | 15 ++++ ...tomerTransportFragmentPersistenceTest.java | 88 +++++++++++++++++++ .../UnifiedInboundQueryServiceTest.java | 22 +++++ .../client/FirstlegUnifiedOrderClient.java | 6 +- .../order/dto/UnifiedOrderResponse.java | 8 +- .../service/UnifiedOrderQueryService.java | 8 +- .../src/main/resources/openapi/order-v1.yaml | 24 +++++ .../UnifiedOrderApiContractTest.java | 15 ++++ .../service/UnifiedOrderQueryServiceTest.java | 19 +++- 15 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerTransportFragmentPersistenceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java new file mode 100644 index 000000000..e5914c7a7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; +import java.util.List; + +/** Customer-safe, read-only transport facts for one actually loaded inbound/container fragment. */ +public record CustomerTransportFragment(String inboundCode, String cabinetNo, String containerNo, + List nodes) { + public record Node(String standardNode, Integer nodeSequence, String providerCode, + LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName) {} + + public record Row(Long inboundId, Long containerBookingId, String inboundCode, String cabinetNo, + String containerNo, String standardNode, Integer nodeSequence, String providerCode, + LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java index e50938cac..c306ff6dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java @@ -6,7 +6,8 @@ import java.util.List; /** Detail payload for the product-level order detail. */ public record UnifiedInboundDetail( UnifiedInboundSummary summary, - List timeline + List timeline, + List transportFragments ) { public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus, String action, String remark, Long operatorId, String operatorName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java index c90eb6763..e7b4c7583 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java @@ -10,6 +10,29 @@ import java.util.List; /** 统一订单按当前有效装柜明细批量读取柜次船期。 */ @Mapper public interface UnifiedInboundTransportMapper { + @Select(""" + SELECT owned.inbound_id, owned.container_booking_id, owned.inbound_code, + b.cabinet_no, b.container_no, n.standard_node, n.node_sequence, n.provider_code, + n.estimated_at, n.actual_at, n.location_code, n.location_name + FROM ( + SELECT DISTINCT o.id AS inbound_id, p.container_booking_id, o.inbound_code + FROM fl_inbound_order o + JOIN fl_loading_plan_item i ON i.inbound_id=o.id AND i.account_id=o.tenant_id + JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED' + JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id + AND picked.loading_plan_item_id=i.id + WHERE o.tenant_id=#{tenantId} AND o.order_id=#{orderId} AND o.deleted=0 + AND i.deleted=0 AND picked.item_status IN ('PICKED','PARTIAL') + AND picked.picked_package_count>0 + ) owned + JOIN fl_container_booking b ON b.id=owned.container_booking_id + LEFT JOIN fl_carrier_schedule_node_fact n ON n.container_booking_id=owned.container_booking_id + ORDER BY owned.inbound_id, owned.container_booking_id, n.node_sequence, n.id + """) + List findCustomerTransportNodes( + @Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select(""" ") List rows(@Param("customerId") String customerId, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java new file mode 100644 index 000000000..e77c9dd02 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundPlanDispatchStatusPersistenceTest { + private JdbcTemplate db; + private InboundPlanQueryMapper mapper; + + @BeforeEach + void setUp() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + db = new JdbcTemplate(data); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,plan_no VARCHAR(50),customer_id VARCHAR(20),overseas_warehouse_id BIGINT,assigned_flag BOOLEAN,merge_required BOOLEAN,urgent_flag BOOLEAN,status VARCHAR(30),created_at TIMESTAMP,version INT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),destination_warehouse_id VARCHAR(30),destination_warehouse_code VARCHAR(30),destination_warehouse_name VARCHAR(50),fba_warehouse_code VARCHAR(30),required_package_count INT,volume_cbm DECIMAL(18,3),planned_pallet_count INT)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),lx_code VARCHAR(50),order_type VARCHAR(30),overseas_warehouse_code VARCHAR(30),overseas_warehouse_name VARCHAR(50),customer_name VARCHAR(50))"); + db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,line_id BIGINT,appointment_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,customer_id VARCHAR(20),created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT,picked_package_count INT)"); + db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),status VARCHAR(30))"); + db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)"); + db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')"); + var config = new MybatisConfiguration(); + config.addMapper(InboundPlanQueryMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(data); + factory.setConfiguration(config); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundPlanQueryMapper.class); + } + + @Test + void cancelledDispatchRetainsHistoryButDoesNotMarkOrFilterTheInboundAsScheduled() { + dispatch(10, "A", "CANCELLED", "A"); + assertStatus("UNSCHEDULED"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item", Integer.class)).isEqualTo(1); + } + + @Test + void replacementDispatchCountsOnceAndCancellingItRestoresUnscheduled() { + dispatch(10, "A", "CANCELLED", "A"); + dispatch(11, "A", "PENDING_DEPARTURE", "A"); + dispatch(12, "A", "PICKING", "A"); + assertStatus("SCHEDULED"); + db.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); + assertStatus("UNSCHEDULED"); + } + + @Test + void departedAndCompletedDispatchesRemainScheduled() { + dispatch(10, "A", "DEPARTED", "A"); + assertStatus("SCHEDULED"); + db.update("UPDATE ll_dispatch_order SET status='COMPLETED'"); + assertStatus("SCHEDULED"); + } + + @Test + void otherCustomerOrMissingDispatchCannotMarkThisInboundScheduled() { + dispatch(10, "B", "PENDING_DEPARTURE", "B"); + dispatch(11, "B", "PENDING_DEPARTURE", "A"); + db.update("INSERT INTO ll_dispatch_item VALUES(12,'A',999,3)"); + assertStatus("UNSCHEDULED"); + assertThat(mapper.rows("B", query(null), 0, 20)).isEmpty(); + assertThat(mapper.count("B", query(null))).isZero(); + } + + private void dispatch(long id, String owner, String status, String itemOwner) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status); + db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); + } + + private void assertStatus(String status) { + for (String customer : new String[]{null, "A"}) { + assertThat(mapper.rows(customer, query(null), 0, 20)) + .extracting(InboundPlanListResponse::dispatchStatus).containsExactly(status); + assertThat(mapper.rows(customer, query(status), 0, 20)).hasSize(1); + assertThat(mapper.count(customer, query(status))).isEqualTo(1); + String opposite = "SCHEDULED".equals(status) ? "UNSCHEDULED" : "SCHEDULED"; + assertThat(mapper.rows(customer, query(opposite), 0, 20)).isEmpty(); + assertThat(mapper.count(customer, query(opposite))).isZero(); + } + } + + private static InboundPlanRequests.Query query(String status) { + return new InboundPlanRequests.Query(1, 20, null, null, null, null, null, null, + null, null, null, status, null, null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java index cb5fe1ea8..4adc78fa9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java @@ -19,6 +19,8 @@ class InboundPlanQueryMapperContractTest { assertTrue(sql.contains("l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType")); assertTrue(sql.contains("p.overseas_warehouse_id overseasWarehouseId")); assertTrue(sql.contains("p.overseas_warehouse_id=#{q.overseasWarehouseId}")); - assertTrue(sql.contains("WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id")); + assertTrue(sql.contains("di.customer_id=p.customer_id AND di.order_id=l.order_id")); + assertTrue(sql.contains("dispatch.customer_id=di.customer_id")); + assertTrue(sql.contains("dispatch.status<>'CANCELLED'")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 3db84f19c..00890c2a2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -82,7 +82,8 @@ class InboundPlanPaginationTest { .getMethod("rows", String.class, InboundPlanRequests.Query.class, long.class, long.class) .getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER; assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1"); - assertThat(sql).contains("di.customer_id=#{customerId} AND di.order_id=l.order_id"); + assertThat(sql).contains("di.customer_id=p.customer_id AND di.order_id=l.order_id", + "dispatch.customer_id=di.customer_id", "dispatch.status<>'CANCELLED'"); assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode", "o.overseas_warehouse_name overseasWarehouseName", "p.overseas_warehouse_id=#{q.overseasWarehouseId}"); -- 2.54.0 From 5ea1c277bc4105e00e7f5dc739ceeb3a7398bd0e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 04:11:33 +0800 Subject: [PATCH 158/176] fix(test): retain safe inbound resource fault diagnostics --- .../inbound-plan-resource-fault-executor.mjs | 13 +++++- ...ound-plan-resource-fault-executor.test.mjs | 12 +++++ .../inbound-plan-resource-fault-runtime.py | 45 ++++++++++++++++--- ...bound-plan-resource-fault-runtime.test.mjs | 12 ++++- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/scripts/inbound-plan-resource-fault-executor.mjs b/scripts/inbound-plan-resource-fault-executor.mjs index e98556768..b02e30992 100644 --- a/scripts/inbound-plan-resource-fault-executor.mjs +++ b/scripts/inbound-plan-resource-fault-executor.mjs @@ -61,7 +61,16 @@ export function createInboundResourceRuntime(env, scope, fetcher=globalThis.fetc const text=await response.text();need(!text.includes('MultipleCompilationErrorsException'),'RENDERER_COMPILATION_FAILED_NO_ACTION'); const rows=text.split(/\r?\n/).filter(row=>row.startsWith('INBOUND_RESOURCE_JSON='));need(rows.length===1,'AMBIGUOUS_RESPONSE_NO_AUTO_RETRY'); let result;try {result=JSON.parse(rows[0].slice('INBOUND_RESOURCE_JSON='.length));}catch{throw Error('INVALID_RESPONSE_NO_AUTO_RETRY');} - need(result?.ok===true,/^[A-Z_0-9]{1,90}$/.test(result?.error||'')?result.error:'RUNTIME_ACTION_FAILED');return result.data; + if(result?.ok!==true) { + const error=Error(/^[A-Z_0-9]{1,90}$/.test(result?.error||'')?result.error:'RUNTIME_ACTION_FAILED'); + const d=result?.diagnostic; + if(d&&/^[A-Z_0-9]{1,90}$/.test(d.stage||'')&&/^[A-Za-z_]{1,80}$/.test(d.errorType||'')) + error.runtimeDiagnostic={stage:d.stage,errorType:d.errorType,...(Number.isInteger(d.httpStatus)&&d.httpStatus>=100&&d.httpStatus<=599?{httpStatus:d.httpStatus}:{}), + ...(/^[A-Za-z_]{1,80}$/.test(d.transportErrorType||'')?{transportErrorType:d.transportErrorType}:{}), + ...(Number.isInteger(d.errno)&&d.errno>=-10000&&d.errno<=10000?{errno:d.errno}:{})}; + throw error; + } + return result.data; }; } async function main(argv) { @@ -74,4 +83,4 @@ async function main(argv) { if(args.mode)extra.mode=args.mode;if(args.ttl)extra.ttl=Number(args.ttl); console.log(JSON.stringify(await createInboundResourceRuntime(process.env,scope)(action,extra),null,2)); } -if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'INBOUND_RESOURCE_ARGUMENT_ERROR');process.exitCode=1;}); +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(JSON.stringify({error:/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'INBOUND_RESOURCE_ARGUMENT_ERROR',diagnostic:error.runtimeDiagnostic}));process.exitCode=1;}); diff --git a/scripts/inbound-plan-resource-fault-executor.test.mjs b/scripts/inbound-plan-resource-fault-executor.test.mjs index f0cb85ce8..bb76d2490 100644 --- a/scripts/inbound-plan-resource-fault-executor.test.mjs +++ b/scripts/inbound-plan-resource-fault-executor.test.mjs @@ -46,6 +46,18 @@ test('transport unknown, duplicate result and malformed result each fail after e } assert.throws(()=>createInboundResourceRuntime({...env,CHONGCHONG_ENV:'prod'},scope)); }); +test('remote error preserves safe stage/type/status and strips untrusted diagnostic fields',async()=>{ + const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_CONFIG_READ_HTTP_404',diagnostic:{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404,token:'must-never-escape',body:'secret',url:'http://private.invalid'}})})); + await assert.rejects(runtime('preflight',{routing}),error=>{ + assert.equal(error.message,'NACOS_CONFIG_READ_HTTP_404');assert.deepEqual(error.runtimeDiagnostic,{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404});return true; + }); +}); +test('DNS diagnostics preserve only bounded errno and transport class, no private resolver message',async()=>{ + const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_SOURCE_READ_TRANSPORT_FAILED',diagnostic:{stage:'NACOS_SOURCE_READ',errorType:'URLError',transportErrorType:'gaierror',errno:-2,message:'private resolver secret'}})})); + await assert.rejects(runtime('preflight',{routing}),error=>{ + assert.deepEqual(error.runtimeDiagnostic,{stage:'NACOS_SOURCE_READ',errorType:'URLError',transportErrorType:'gaierror',errno:-2});return true; + }); +}); test('default plan CLI has zero HTTP and exposes no business conversion action',()=>{ const dir=mkdtempSync(join(tmpdir(),'inbound-fault-plan-'));const file=join(dir,'plan.json');writeFileSync(file,JSON.stringify(plan)); const run=spawnSync(process.execPath,[fileURLToPath(new URL('./inbound-plan-resource-fault-executor.mjs',import.meta.url)),'plan','--plan',file],{encoding:'utf8',env:{...process.env,CHONGCHONG_ENV:'prod',JENKINS_URL:'http://127.0.0.1:1'}}); diff --git a/scripts/inbound-plan-resource-fault-runtime.py b/scripts/inbound-plan-resource-fault-runtime.py index 9f1473d2e..d1d9f4d2f 100644 --- a/scripts/inbound-plan-resource-fault-runtime.py +++ b/scripts/inbound-plan-resource-fault-runtime.py @@ -18,11 +18,37 @@ import sys import time import urllib.parse import urllib.request +import urllib.error from pathlib import Path TARGET = 'firstleg-test-lastleg' UPSTREAM = 'firstleg-test-warehouse-overseas' PROPERTY = 'baiyue.warehouse.base-url' +STAGE = 'INITIALIZE' + + +def safe_diagnostic(error): + """Never return exception messages, URLs, bodies, environment or identities.""" + stage, status = STAGE, None + if isinstance(error, urllib.error.HTTPError): + endpoint = urllib.parse.urlsplit(error.geturl()).path + stage = {'/nacos/v3/auth/user/login': 'NACOS_LOGIN', + '/nacos/v3/admin/cs/config': 'NACOS_CONFIG_READ'}.get(endpoint, stage) + status = error.code if type(error.code) is int and 100 <= error.code <= 599 else None + kind = type(error).__name__ + diagnostic = {'stage': stage, 'errorType': kind if re.fullmatch(r'[A-Za-z_]{1,80}', kind) else 'Exception'} + if isinstance(error, urllib.error.URLError) and not isinstance(error, urllib.error.HTTPError): + reason = error.reason + reason_type = type(reason).__name__ + if re.fullmatch(r'[A-Za-z_]{1,80}', reason_type): diagnostic['transportErrorType'] = reason_type + number = getattr(reason, 'errno', None) + if type(number) is int and -10000 <= number <= 10000: diagnostic['errno'] = number + if type(status) is int and 100 <= status <= 599: diagnostic['httpStatus'] = status + message = str(error) + code = message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'INBOUND_RESOURCE_ACTION_FAILED' + if status is not None: code = stage + '_HTTP_' + str(status) + elif isinstance(error, urllib.error.URLError): code = stage + '_TRANSPORT_FAILED' + return {'ok': False, 'error': code, 'diagnostic': diagnostic} def validate_scope(scope): @@ -183,15 +209,25 @@ def runtime_class(base_source): def execute(config): + global STAGE + STAGE = 'LOAD_REVIEWED_PRIMITIVES' Runtime, namespace = runtime_class(base64.b64decode(config['baseSource']).decode()) runtime = Runtime(config['scope'], namespace['common_tools'](base64.b64decode(config['commonSource']).decode())) action = config['action'] if action == 'preflight': + STAGE = 'LASTLEG_DOCKER_READ' current = runtime.container() + STAGE = 'WAREHOUSE_UPSTREAM_IDENTITY' target = runtime.verified_upstream(current, config['routing'], runtime.tools['env_of'](current).get('INTERNAL_SERVICE_TOKEN', '')) - return {'originalImageId': current['Image'], 'originalFingerprint': runtime.tools['fingerprint'](current), - 'sourceSha256': runtime.config_fingerprint(current, config['routing']), 'runtimeChanged': False, - 'upstreamImageId': target['Image'], 'upstreamFingerprint': runtime.tools['fingerprint'](target)} + STAGE = 'LASTLEG_FULL_FINGERPRINT' + original_fingerprint = runtime.tools['fingerprint'](current) + STAGE = 'NACOS_SOURCE_READ' + source_hash = runtime.config_fingerprint(current, config['routing']) + STAGE = 'WAREHOUSE_FULL_FINGERPRINT' + upstream_fingerprint = runtime.tools['fingerprint'](target) + return {'originalImageId': current['Image'], 'originalFingerprint': original_fingerprint, + 'sourceSha256': source_hash, 'runtimeChanged': False, + 'upstreamImageId': target['Image'], 'upstreamFingerprint': upstream_fingerprint} if action == 'prepare': return runtime.prepare(config) state = runtime.load() if action == 'status': @@ -234,5 +270,4 @@ def main(config): if __name__ == '__main__': try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(json.loads(base64.b64decode(sys.argv[1])))})) except Exception as error: - message = str(error) - print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': False, 'error': message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'INBOUND_RESOURCE_ACTION_FAILED'})) + print('INBOUND_RESOURCE_JSON=' + json.dumps(safe_diagnostic(error))) diff --git a/scripts/inbound-plan-resource-fault-runtime.test.mjs b/scripts/inbound-plan-resource-fault-runtime.test.mjs index 4e215d9e2..71e7e5e78 100644 --- a/scripts/inbound-plan-resource-fault-runtime.test.mjs +++ b/scripts/inbound-plan-resource-fault-runtime.test.mjs @@ -3,9 +3,19 @@ import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; const script=String.raw` -import importlib.util,sys,json,hashlib,copy +import importlib.util,sys,json,hashlib,copy,urllib.error from pathlib import Path spec=importlib.util.spec_from_file_location('resource_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +m.STAGE='NACOS_SOURCE_READ' +for endpoint,stage in [('/nacos/v3/auth/user/login','NACOS_LOGIN'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_READ')]: + d=m.safe_diagnostic(urllib.error.HTTPError('http://user:password@private.invalid'+endpoint+'?token=secret',404,'secret body',{},None)) + assert d=={'ok':False,'error':stage+'_HTTP_404','diagnostic':{'stage':stage,'errorType':'HTTPError','httpStatus':404}} + assert 'secret' not in json.dumps(d) and 'private' not in json.dumps(d) +assert m.safe_diagnostic(TypeError('private-secret-response'))=={'ok':False,'error':'INBOUND_RESOURCE_ACTION_FAILED','diagnostic':{'stage':'NACOS_SOURCE_READ','errorType':'TypeError'}} +import socket +dns=m.safe_diagnostic(urllib.error.URLError(socket.gaierror(-2,'private DNS hostname secret'))) +assert dns=={'ok':False,'error':'NACOS_SOURCE_READ_TRANSPORT_FAILED','diagnostic':{'stage':'NACOS_SOURCE_READ','errorType':'URLError','transportErrorType':'gaierror','errno':-2}} +assert 'private' not in json.dumps(dns) and 'secret' not in json.dumps(dns) base=Path(sys.argv[1]).with_name('order-submit-dependency-fault-runtime.py').read_text() C,ns=m.runtime_class(base) scope={'environment':'TEST','marker':'OFFLINE','containerBookingId':'2106714159440728066','warehouseId':'193000000000200003','batchPrefixHash':hashlib.sha256(json.dumps('2106714159440728066').encode()).hexdigest(),'planSha256':'a'*64} -- 2.54.0 From 3afb3bcfd18238c133605153897601d608d0a970 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 04:52:38 +0800 Subject: [PATCH 159/176] fix(test): stream resource fault configuration through stdin --- scripts/README.md | 2 ++ .../inbound-plan-resource-fault-executor.mjs | 6 ++++- ...ound-plan-resource-fault-executor.test.mjs | 27 +++++++++++++++++++ .../inbound-plan-resource-fault-runtime.py | 14 ++++++++-- ...bound-plan-resource-fault-runtime.test.mjs | 18 +++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index d651a99ce..b36624fb6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,6 +4,8 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`:只有完整逐箱作业事实后才可绑定实物托盘,不用默认数量补齐缺失事实。已明确拒绝的绑定仅允许当前已冻结原票、原批次、原命令摘要的 `F33_BIND_A0409_AFTER_UNIT` 恢复,必须同时核对不可变失败journal、完整回滚证明和新作业事实/原资源版本,恢复写入独立journal,不覆盖原失败。未知结果、其他拒绝和其他阶段仍禁止自动重试;响应message脱敏留存。该受限恢复只用于原TEST验收,不放宽生产接口或变更库存规则。 +远端执行器仅通过标准输入传递有界UTF-8 JSON配置,不将包含源码的配置放入进程参数,避免Linux单参数长度限制;输入上限1MiB,运行代码参数小于64KiB。进程启动失败和stdin写入结果未知分别返回安全错误码;仅解析一条结构化结果,不输出完整配置、底层异常正文或凭据。stdin结果未知仍先按原marker核对状态,不能自动重试prepare或容器变更。 + `inbound-plan-resource-fault-proxy.py --config <权限0600的私有配置>` 是柜级入仓计划资源确认的 TEST 定向故障工具。导入不执行,运行才监听业务端口与回环控制端口;复用 `order-submit-dependency-fault-proxy.py` 的 HTTP 传输和服务,配置必须显式 `environment=TEST`,冻结柜ID、海外仓ID、按当前协调器JSON字符串算法计算的柜批次前缀、Lastleg来源IP和内部身份。默认直通;持有私有控制凭据的 `CONFIRM_ONCE` 最长60秒,只让该柜精确 `/internal/v1/warehouse-pallet-batches/confirm` POST 返回一次503,随后自动直通。其他柜、预留/查询、错身份及不匹配请求保留原请求响应。凭据只读私有配置,脱敏证据写同目录 `proxy-evidence.jsonl`,不保存正文或令牌。工具不修改SQL或Docker,不自行重试业务或恢复服务路由;运行者须先安排独占故障窗口、记录并恢复原Lastleg配置/镜像及健康、停止本代理,结果未知先核对,不盲目重启。`inbound-plan-resource-fault-proxy.test.mjs` 为离线范围、单次并发、TTL与脱敏测试;工具检查通过不等于已执行真实故障与页面恢复。 `partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜的 TEST 专项入口,默认无参数或 `--plan` 零网络。使用显式授权 TEST 环境文件,匹配 Firstleg/触发器就绪并开放独占样本窗口后,才可传 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,或用 `--fixture=` 提供本轮已核对来源。新准备只走客户资格、下单、测量、装柜正式接口;A 覆盖混合客户/税制、短拣和跳过,B 的全部实装行均为 PARTIAL、退税来源全部跳过。检查实际数量/指标、报关范围及同命令重放;合成图片只证明 HTTP,不是现场证据。报告保存至 `reports/partial-loading-actual-PARTIAL-ACTUAL-<批次>/`,先记录写入意图,拒绝覆盖;失败保留原样本和报告,未知结果不得自动重造或重试。仅在明确续跑授权和原快照核对后接续,退出自己的会话;不执行 SQL/部署、船节点或历史回填,不代替事件 ACK/下游库存、完整报关文件和页面/设备验收。直接测试 `partial-loading-actual-test-env.test.mjs`。 diff --git a/scripts/inbound-plan-resource-fault-executor.mjs b/scripts/inbound-plan-resource-fault-executor.mjs index b02e30992..3769e0f97 100644 --- a/scripts/inbound-plan-resource-fault-executor.mjs +++ b/scripts/inbound-plan-resource-fault-executor.mjs @@ -44,8 +44,12 @@ export function renderInboundResourceAction(action, scope, agent, extra={}) { routingFor(action,extra); if(action==='mode')need(['PASS','CONFIRM_ONCE'].includes(extra.mode)&&(extra.mode==='PASS'||Number.isInteger(extra.ttl)&&extra.ttl>=1&&extra.ttl<=60),'INVALID_MODE_OR_TTL'); const config={...extra,action,scope,baseSource,commonSource,...(action==='prepare'?{proxySource,httpProxySource}:{})}; + const configJson=JSON.stringify(config); + need(Buffer.byteLength(configJson,'utf8')<=1024*1024,'RUNTIME_STDIN_CONFIG_TOO_LARGE'); + need(runtime.length<65536,'RUNTIME_CODE_ARG_TOO_LARGE'); const literal=text=>'['+encode(text).match(/.{1,4000}/g).map(part=>`'${part}'`).join(',')+"].join('')"; - const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${literal(runtime)}),'UTF-8'),${literal(JSON.stringify(config))}]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)`; + const safeResult=(code,stage)=>`print('INBOUND_RESOURCE_JSON='+groovy.json.JsonOutput.toJson([ok:false,error:'${code}',diagnostic:[stage:'${stage}',errorType:'IOException']]))`; + const groovy=`def p;try{p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${literal(runtime)}),'UTF-8')]).redirectErrorStream(true).start()}catch(java.io.IOException e){${safeResult('REMOTE_PROCESS_START_FAILED','REMOTE_PROCESS_START')};return};try{p.outputStream.withCloseable{s->s.write(java.util.Base64.decoder.decode(${literal(configJson)}));s.flush()}}catch(java.io.IOException e){${safeResult('RUNTIME_STDIN_OUTCOME_UNKNOWN_USE_STATUS','REMOTE_PROCESS_STDIN')};return};def out=p.inputStream.getText('UTF-8');p.waitFor();def rows=out.readLines().findAll{it.startsWith('INBOUND_RESOURCE_JSON=')};if(rows.size()==1){print(rows[0])}else{print('INBOUND_RESOURCE_JSON='+groovy.json.JsonOutput.toJson([ok:false,error:'AMBIGUOUS_RESPONSE_NO_AUTO_RETRY']))}`; return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${literal(groovy)}),'UTF-8'),c.channel))`; } export function createInboundResourceRuntime(env, scope, fetcher=globalThis.fetch) { diff --git a/scripts/inbound-plan-resource-fault-executor.test.mjs b/scripts/inbound-plan-resource-fault-executor.test.mjs index bb76d2490..327476387 100644 --- a/scripts/inbound-plan-resource-fault-executor.test.mjs +++ b/scripts/inbound-plan-resource-fault-executor.test.mjs @@ -29,6 +29,27 @@ test('renderer chunks complete payload and carries both proxy modules and review assert.deepEqual(config.scope,scope);assert.equal(config.action,'prepare'); for(const key of ['proxySource','httpProxySource','commonSource','baseSource'])assert.ok(config[key].length>100); const runtime=Buffer.from(embedded[0].join(''),'base64').toString();assert.ok(runtime.includes('firstleg-test-lastleg')); + assert.match(groovy,/new ProcessBuilder\(\['python3','-c',new String\(.+?,'UTF-8'\)\]\)/s); + assert.match(groovy,/p\.outputStream\.withCloseable\{s->s\.write\(java\.util\.Base64\.decoder\.decode\(/); + assert.ok(Buffer.byteLength(runtime)<65536); + assert.ok(embedded[1].join('').length>131072,'prepare reproduces the old over-limit configuration argv'); + assert.ok(runtime.includes('main(read_stdin_config())')); + assert.ok(!runtime.includes('base64.b64decode(sys.argv[1])')); +}); +test('complete prepare configuration travels over stdin to actual Python with bounded argv and no config echo',()=>{ + const rendered=renderInboundResourceAction('prepare',scope,'test-agent',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}); + const groovy=Buffer.from(arrays(rendered)[0].join(''),'base64').toString();const embedded=arrays(groovy); + const runtime=Buffer.from(embedded[0].join(''),'base64').toString(); + const config=JSON.parse(Buffer.from(embedded[1].join(''),'base64').toString()); + // Reject scope before locks/Docker/network. JSON still must consume the complete four-source body. + config.scope.environment='PROD';const input=JSON.stringify(config); + const argv=['-c',runtime];assert.equal(argv.length,2);assert.ok(argv.every(a=>Buffer.byteLength(a)<65536)); + assert.ok(Buffer.byteLength(input)>100000);for(const key of ['baseSource','commonSource','proxySource','httpProxySource'])assert.ok(config[key].length>100); + const result=spawnSync(process.env.PYTHON||'python',argv,{input,encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}); + assert.equal(result.status,0,result.stderr||result.error?.message);assert.equal(result.stderr,''); + const rows=result.stdout.trim().split(/\r?\n/);assert.equal(rows.length,1);assert.ok(rows[0].startsWith('INBOUND_RESOURCE_JSON=')); + const value=JSON.parse(rows[0].slice('INBOUND_RESOURCE_JSON='.length));assert.equal(value.error,'EXPLICIT_TEST_SCOPE_REQUIRED'); + assert.equal(value.diagnostic.stage,'READ_STDIN_CONFIG');assert.ok(!result.stdout.includes(config.baseSource.slice(0,80)));assert.ok(!result.stdout.includes('upstreams')); }); test('origin/schema guards reject credential embedding, wrong imports and unbounded fault windows',()=>{ for(const change of [r=>r.password='secret',r=>r.upstreams.IAM='http://offline.invalid',r=>r.nacos.baseUrl='http://user:secret@offline.invalid',r=>r.nacos.configPath='/fallback']){ @@ -46,6 +67,12 @@ test('transport unknown, duplicate result and malformed result each fail after e } assert.throws(()=>createInboundResourceRuntime({...env,CHONGCHONG_ENV:'prod'},scope)); }); +test('structured remote process-start/stdin errors remain single results and never echo private payloads',async()=>{ + for(const [code,stage] of [['REMOTE_PROCESS_START_FAILED','REMOTE_PROCESS_START'],['RUNTIME_STDIN_OUTCOME_UNKNOWN_USE_STATUS','REMOTE_PROCESS_STDIN']]){ + let calls=0;const runtime=createInboundResourceRuntime(env,scope,async()=>{calls++;return{ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:code,diagnostic:{stage,errorType:'IOException'}})}}); + await assert.rejects(runtime('prepare',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}),error=>{assert.equal(error.message,code);assert.deepEqual(error.runtimeDiagnostic,{stage,errorType:'IOException'});return true;});assert.equal(calls,1); + } +}); test('remote error preserves safe stage/type/status and strips untrusted diagnostic fields',async()=>{ const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_CONFIG_READ_HTTP_404',diagnostic:{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404,token:'must-never-escape',body:'secret',url:'http://private.invalid'}})})); await assert.rejects(runtime('preflight',{routing}),error=>{ diff --git a/scripts/inbound-plan-resource-fault-runtime.py b/scripts/inbound-plan-resource-fault-runtime.py index d1d9f4d2f..d20a4625f 100644 --- a/scripts/inbound-plan-resource-fault-runtime.py +++ b/scripts/inbound-plan-resource-fault-runtime.py @@ -229,6 +229,7 @@ def execute(config): 'sourceSha256': source_hash, 'runtimeChanged': False, 'upstreamImageId': target['Image'], 'upstreamFingerprint': upstream_fingerprint} if action == 'prepare': return runtime.prepare(config) + STAGE = 'RUNTIME_STATE_READ' state = runtime.load() if action == 'status': result = runtime.summary(state) @@ -255,8 +256,8 @@ def execute(config): def main(config): - import fcntl validate_scope(config['scope']) + import fcntl file = Path('/tmp') / ('chongchong-inbound-resource-' + config['scope']['marker'] + '.action.lock') if file.is_symlink(): raise RuntimeError('UNSAFE_ACTION_LOCK') fd = os.open(file, os.O_RDWR | os.O_CREAT, 0o600) @@ -267,7 +268,16 @@ def main(config): finally: os.close(fd) +def read_stdin_config(stream=None): + """One bounded UTF-8 JSON stdin protocol; configuration is never argv.""" + global STAGE + STAGE = 'READ_STDIN_CONFIG' + raw = (sys.stdin.buffer if stream is None else stream).read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: raise RuntimeError('RUNTIME_STDIN_CONFIG_TOO_LARGE') + return json.loads(raw.decode('utf-8')) + + if __name__ == '__main__': - try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(json.loads(base64.b64decode(sys.argv[1])))})) + try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(read_stdin_config())})) except Exception as error: print('INBOUND_RESOURCE_JSON=' + json.dumps(safe_diagnostic(error))) diff --git a/scripts/inbound-plan-resource-fault-runtime.test.mjs b/scripts/inbound-plan-resource-fault-runtime.test.mjs index 71e7e5e78..6f20e27b9 100644 --- a/scripts/inbound-plan-resource-fault-runtime.test.mjs +++ b/scripts/inbound-plan-resource-fault-runtime.test.mjs @@ -4,8 +4,16 @@ import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; const script=String.raw` import importlib.util,sys,json,hashlib,copy,urllib.error +import io from pathlib import Path spec=importlib.util.spec_from_file_location('resource_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +payload={'scope':{'marker':'stdin'},'complete':'x'*200000} +assert m.read_stdin_config(io.BytesIO(json.dumps(payload).encode()))==payload +try:m.read_stdin_config(io.BytesIO(b'x'*(1024*1024+1)));raise AssertionError('unbounded config accepted') +except RuntimeError as e:assert str(e)=='RUNTIME_STDIN_CONFIG_TOO_LARGE' +try:m.read_stdin_config(io.BytesIO(b'{"private":"secret"'));raise AssertionError('partial JSON accepted') +except json.JSONDecodeError as e: + d=m.safe_diagnostic(e);assert d['diagnostic']['stage']=='READ_STDIN_CONFIG';assert 'secret' not in json.dumps(d) m.STAGE='NACOS_SOURCE_READ' for endpoint,stage in [('/nacos/v3/auth/user/login','NACOS_LOGIN'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_READ')]: d=m.safe_diagnostic(urllib.error.HTTPError('http://user:password@private.invalid'+endpoint+'?token=secret',404,'secret body',{},None)) @@ -59,6 +67,16 @@ for key,value in [('environment','PROD'),('warehouseId','0'),('batchPrefixHash', bad=scope|{key:value} try:m.validate_scope(bad);raise AssertionError('invalid scope accepted') except RuntimeError:pass +import base64 +original_factory=m.runtime_class +class MissingState: + def __init__(self,*_):pass + def load(self):raise FileNotFoundError('private-state-path-must-not-escape') +m.runtime_class=lambda _:(MissingState,{'common_tools':lambda _: {}}) +try:m.execute({'action':'status','scope':scope,'baseSource':base64.b64encode(b'offline').decode(),'commonSource':base64.b64encode(b'offline').decode()});raise AssertionError('missing state accepted') +except FileNotFoundError as e: + d=m.safe_diagnostic(e);assert d['diagnostic']=={'stage':'RUNTIME_STATE_READ','errorType':'FileNotFoundError'};assert 'private' not in json.dumps(d) +m.runtime_class=original_factory print('OFFLINE_RUNTIME_PASS') `; test('reviewed Docker methods retain exact target, one-property overlay, journal uncertainty and restore-before-cleanup',()=>{ -- 2.54.0 From 47acfc991bead7c41c548f63a122d651317a5ced Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 05:26:01 +0800 Subject: [PATCH 160/176] fix(lastleg): keep unconfirmed container resources recoverable --- .../ContainerPlanConversionCommandMapper.java | 8 ++++ .../inboundplan/mapper/InboundPlanMapper.java | 5 +++ .../mapper/InboundPlanOrderMapper.java | 3 +- .../service/ReceivingContainerService.java | 8 +++- ...ainerInboundPlanConversionCoordinator.java | 9 +++- .../lastleg/mapper/LastlegOrderMapper.java | 5 ++- ...nfirmedContainerResourcesContractTest.java | 45 +++++++++++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 18 ++++++++ ...rInboundPlanConversionCoordinatorTest.java | 27 +++++++++++ .../ContainerInboundPlanPreviewTest.java | 24 ++++++++++ 10 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java index a4bc87cc3..a9053503e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java @@ -11,6 +11,14 @@ import java.time.LocalDateTime; @Mapper public interface ContainerPlanConversionCommandMapper extends BaseMapper { + // Only plans listed in this durable command are pending; older plans in the same cabinet stay confirmed. + String UNCONFIRMED_PLAN = "EXISTS (SELECT 1 FROM ll_container_plan_conversion_command rc " + + "JOIN JSON_TABLE(rc.preparation_json, '$.items[*]' COLUMNS (" + + "customer_id VARCHAR(20) PATH '$.customerId' ERROR ON EMPTY ERROR ON ERROR," + + "plan_id BIGINT PATH '$.planId' ERROR ON EMPTY ERROR ON ERROR)) resource_item " + + "ON resource_item.plan_id=p.id AND CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY) " + + "WHERE rc.source_container_booking_id=o.source_container_booking_id AND rc.status='LOCAL_COMMITTED')"; + @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} AND idempotency_key=#{key} LIMIT 1") ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 534969f23..d572e8e41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -49,6 +49,11 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + @Select("SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " + + "AND o.id=p.source_order_id WHERE p.customer_id=#{customerId} AND p.id=#{planId} AND " + + ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN) + long countUnconfirmedResourcePlans(@Param("customerId") String customerId, @Param("planId") long planId); + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " + "WHERE id=#{planId} AND customer_id=#{customerId} " + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 0569b86d0..c21a77c65 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -47,7 +47,8 @@ public interface InboundPlanOrderMapper { "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," + "p.pallet_count AS plannedPalletCount," + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + - "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL AND NOT " + + ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + ") AS materializedPalletCount," + "(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " + "AND sp.order_id=o.id) AS sourcePalletCount " + "FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 63ed8f26a..b4998a5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -716,11 +716,14 @@ public class ReceivingContainerService { && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; - boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; + boolean resourcesConfirmed = matchedPlans.stream().noneMatch(plan -> + plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0); + boolean operable = allPlansReady && resourcesConfirmed && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent ? "柜内接柜仓业务快照不一致" : !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码" + : !resourcesConfirmed ? "入库计划资源尚未确认,请先在平台恢复原柜级转换命令" : "柜号尚未达到海外收货可操作节点"); LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt) .filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null); @@ -958,6 +961,9 @@ public class ReceivingContainerService { private void requireMaterializedPlans(List containerPlans) { for (InboundPlan plan : containerPlans) { + if (plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0) { + throw conflict("入库计划资源尚未确认,请先在平台恢复原柜级转换命令"); + } int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount(); long materialized = pallets.selectCount(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 31b7f2542..13e3782bc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -81,9 +81,11 @@ public class ContainerInboundPlanConversionCoordinator { .anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness())); if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual; + var retry = preview(bookingId).resourceRetry(); List materialized = confirm(new ConvertContainerInboundPlanRequest( - bookingId, preview.snapshotVersion(), preview.snapshotHash()), - "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey))); + bookingId, retry == null ? preview.snapshotVersion() : retry.snapshotVersion(), + retry == null ? preview.snapshotHash() : retry.snapshotHash()), + retry == null ? "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey)) : retry.idempotencyKey()); return materialized.stream() .filter(plan -> Objects.equals(plan.customerId(), manual.customerId()) && Objects.equals(plan.sourceOrderId(), manual.sourceOrderId())) @@ -104,6 +106,9 @@ public class ContainerInboundPlanConversionCoordinator { } else if ("FAILED".equals(resolved.getStatus())) { resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash); } else { + if ("LOCAL_COMMITTED".equals(resolved.getStatus()) && !key.equals(resolved.getIdempotencyKey())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "资源恢复必须使用原柜级转换命令"); + } requireSameRequest(resolved, requestHash); } if ("COMPLETED".equals(resolved.getStatus())) { 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 2119553a5..ac6290793 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 @@ -71,8 +71,9 @@ public interface LastlegOrderMapper extends BaseMapper { "THEN 1 ELSE 0 END) AS pendingPlanCount," + "SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " + "AND COALESCE(p.pallet_count,0)>0 AND " + - "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + - "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " + + "(" + com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + + " OR (SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0)) " + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + "CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " + "AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java new file mode 100644 index 000000000..d39443749 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class UnconfirmedContainerResourcesContractTest { + @Test + void localPalletRowsCannotHideAnUnconfirmedCommandInPreviewOrCabinetCandidate() throws Exception { + String preview = String.join("", InboundPlanOrderMapper.class.getMethod("containerPreviewOrders", String.class) + .getAnnotation(Select.class).value()); + String progress = String.join("", LastlegOrderMapper.class.getMethod("pageContainerProgress", + LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class).value()); + for (String sql : new String[]{preview, progress}) { + assertTrue(sql.contains("JSON_TABLE")); + assertTrue(sql.contains("rc.status='LOCAL_COMMITTED'")); + assertTrue(sql.contains("resource_item.plan_id=p.id")); + assertTrue(sql.contains("CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY)")); + assertTrue(sql.contains("rc.source_container_booking_id=o.source_container_booking_id")); + assertFalse(sql.contains("rc.status='COMPLETED'")); + } + assertTrue(preview.contains("AND NOT EXISTS")); + String rendered = new XMLLanguageDriver().createSqlSource(new Configuration(), progress, Map.class) + .getBoundSql(Map.of("q", new LastlegOrderRequests.ContainerProgressQuery(1,20,null,null,null), + "limit",20L,"offset",0L)).getSql(); + assertTrue(rendered.contains("JSON_TABLE")); + assertFalse(rendered.contains("<")); + } + + @Test + void pdaResourceGateUsesTheExactPlanAndCustomerRatherThanEveryPlanInTheCabinet() throws Exception { + String sql = String.join("", InboundPlanMapper.class.getMethod("countUnconfirmedResourcePlans", String.class, long.class) + .getAnnotation(Select.class).value()); + assertTrue(sql.contains("p.customer_id=#{customerId}")); + assertTrue(sql.contains("p.id=#{planId}")); + assertTrue(sql.contains("resource_item.plan_id=p.id")); + assertTrue(sql.contains("CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY)")); + assertFalse(sql.contains("CAST(resource_item.customer_id AS UNSIGNED)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index cb3ca9041..f2f13a06e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -123,6 +123,24 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(warehouse); } + @Test + void localCommittedResourcesAreNotPdaCandidatesOrReceivableDespiteCompleteLocalPalletBindings() { + InboundPlan pending = plan("CONT-A"); + when(plans.selectList(any())).thenReturn(List.of(pending)); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of(containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"))); + when(plans.countUnconfirmedResourcePlans(pending.getCustomerId(), pending.getId())).thenReturn(1L); + var candidates = service.candidates("7", 100L); + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0).operable()).isFalse(); + assertThat(candidates.get(0).blockReason()).contains("资源尚未确认"); + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", + new ReceivingContainerRequests.Action("reserved-only"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("资源尚未确认"); + verify(pallets, never()).selectCount(any()); + verifyNoInteractions(warehouse); + } + @Test void prepareIdempotencyHashIncludesContainerPath() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 8a2d4fc7a..49195f710 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -282,6 +282,8 @@ class ContainerInboundPlanConversionCoordinatorTest { assertEquals("FIRST-KEY", reopened.resourceRetry().idempotencyKey()); assertEquals(7L, reopened.resourceRetry().snapshotVersion()); assertEquals(SNAPSHOT_HASH, reopened.resourceRetry().snapshotHash()); + assertEquals(reopened, coordinator.preview(BOOKING_ID)); + verify(commands, never()).insert(any(ContainerPlanConversionCommand.class)); when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed); assertEquals(409, assertThrows(ResponseStatusException.class, () -> coordinator.confirm( new ConvertContainerInboundPlanRequest(BOOKING_ID, reopened.snapshotVersion(), reopened.snapshotHash()), @@ -298,6 +300,31 @@ class ContainerInboundPlanConversionCoordinatorTest { verify(warehouse).confirmPalletBatch(any()); } + @Test + void localCommittedRecoveryRejectsANewKeyEvenWithTheOriginalBody() throws Exception { + var committed = command("FIRST-KEY", "LOCAL_COMMITTED", request()); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.confirm(request(), "NEW-KEY")).getStatusCode().value()); + verify(warehouse, never()).confirmPalletBatch(any()); + verify(plans, never()).convertContainerReserved(any(), anyLong(), any(), any()); + } + + @Test + void manualReplayResumesTheOriginalResourceCommandRatherThanMintingAnotherKey() throws Exception { + ManualInboundPlanRequest manual = manualRequest(); + InboundPlanResponse existing = plan("IP-MANUAL", List.of()); + var committed = command("FIRST-KEY", "LOCAL_COMMITTED", request()); + when(plans.convertManual(manual, "manual-key", "7")).thenReturn(existing); + when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID); + when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder()))); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + var spy = org.mockito.Mockito.spy(coordinator); + doReturn(List.of(existing)).when(spy).confirm(any(ConvertContainerInboundPlanRequest.class), any(String.class)); + assertEquals(existing, spy.convertManualAndAutoMaterialize(manual, "manual-key", "7")); + verify(spy).confirm(eq(request()), eq("FIRST-KEY")); + } + @Test void nonCommittedStatesDoNotExposeAResourceRetryCommand() throws Exception { when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java index ccd9e9fd8..49d5f1540 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -176,6 +176,30 @@ class ContainerInboundPlanPreviewTest { assertEquals("READY", order.readiness()); } + @Test + void unconfirmedLocalPlanStaysPendingBesideAnOlderConfirmedPlanAndCompletesAfterConfirmation() { + var oldPlan = row(1L, "OLD", "FIRST_LEG", "FBA", "ACY2", "Amazon", 1, + "IP-OLD", 1, 1, "PENDING_ASSIGNMENT", 1); + var pending = row(2L, "NEW", "FIRST_LEG", "FBA", "ACY2", "Amazon", 1, + "IP-NEW", 1, 2, "PENDING_ASSIGNMENT", 0); + var confirmed = row(2L, "NEW", "FIRST_LEG", "FBA", "ACY2", "Amazon", 1, + "IP-NEW", 1, 2, "PENDING_ASSIGNMENT", 1); + when(orderMapper.containerPreviewOrders("MIXED")).thenReturn(List.of(oldPlan, pending), + List.of(oldPlan, pending), List.of(oldPlan, confirmed)); + var first = service.previewContainer("MIXED"); + assertEquals(first, service.previewContainer("MIXED")); + assertEquals(1, first.pendingOrderCount()); + assertEquals(1, first.pendingPalletCount()); + assertEquals(1, first.existingPlannedPalletCount()); + assertEquals(List.of("PLANNED", "RESOURCE_PENDING"), first.groups().get(0).orders().stream() + .map(ContainerInboundPlanPreviewResponse.Order::readiness).toList()); + var complete = service.previewContainer("MIXED"); + assertEquals(0, complete.pendingOrderCount()); + assertEquals(2, complete.existingPlannedPalletCount()); + org.mockito.Mockito.verify(orderMapper, org.mockito.Mockito.times(3)).containerPreviewOrders("MIXED"); + org.mockito.Mockito.verifyNoMoreInteractions(orderMapper); + } + private InboundPlanOrderMapper.ContainerPreviewOrderRow row( long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, int recommendedPallets, String planNo, Integer plannedPallets, int version) { -- 2.54.0 From 548a133de60a3997d71325e0a77c7426e1741512 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 06:34:23 +0800 Subject: [PATCH 161/176] fix(transfer): preserve inbound ownership and expose durable receipt recovery --- .../service/RelabelApplicationService.java | 2 +- .../dto/WarehouseTransferResponses.java | 6 ++ ...WarehouseTransferReceiptCommandMapper.java | 5 + .../WarehouseTransferReceiptCoordinator.java | 74 +++++++++++++- .../service/WarehouseTransferService.java | 14 ++- .../openapi/overseas-business-v1.yaml | 60 +++++++++++- .../RelabelApplicationServiceTest.java | 4 +- ...useTransferReceiptOpenApiContractTest.java | 14 +++ ...rehouseTransferReceiptCoordinatorTest.java | 45 ++++++++- .../service/WarehouseTransferServiceTest.java | 98 ++++++++++++++++++- 10 files changed, 306 insertions(+), 16 deletions(-) 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 0051f4775..dec53919b 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 @@ -968,7 +968,7 @@ public class RelabelApplicationService implements WalletResultHandler { LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { String transferOrderBefore = transferOrder.getCurrentStatus(); - transferOrder.setCurrentStatus(transferStatus); + if (!existingStockTransfer) transferOrder.setCurrentStatus(transferStatus); transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setRelabelStatus("COMPLETED"); transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); 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 3f2148aa8..205113cce 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 @@ -26,6 +26,11 @@ public final class WarehouseTransferResponses { public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, Long palletId, String palletNo, int packageCount, String status, Long destinationInventoryPositionId, String skuCode) {} + public record ReceiptTarget(String warehouseId, String warehouseCode, String warehouseName, + Long palletId, String palletNo, Long locationId, String locationCode, + String fbaId, String fbaCode, String fbaName) {} + public record ReceiptCommand(Long sourceInventoryPositionId, String status, String requestHash, + WarehouseTransferRequests.Receipt request, ReceiptTarget target) {} public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String destinationType, String destinationWarehouseCode, String destinationWarehouseName, @@ -51,6 +56,7 @@ public final class WarehouseTransferResponses { List sourceOrders, List items, List cartons, List pallets, List relabelBindings, List allocations, List measurementPhotos, + List receiptCommands, LocalDateTime customerConfirmedAt, LocalDateTime actualShipAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java index f0f40d2dd..fd4053add 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java @@ -8,6 +8,11 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface WarehouseTransferReceiptCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferId} ORDER BY id") + java.util.List listTransfer(@Param("customerId") String customerId, + @Param("transferId") Long transferId); + @Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " + "AND transfer_order_id=#{transferId} AND source_inventory_position_id=#{sourceId} LIMIT 1 FOR UPDATE") WarehouseTransferReceiptCommand lockSource(@Param("customerId") String customerId, 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 c31bda765..e952751bf 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 @@ -54,6 +54,72 @@ public class WarehouseTransferReceiptCoordinator { private record ResolvedTarget(Target target, String palletStatus) { } private record Admission(WarehouseTransferOrder transfer, boolean replay) { } + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public java.util.List receiptCommands(String customerId, long transferId) { + var transfer = transfers.selectById(transferId); + if (transfer == null || !Objects.equals(customerId, transfer.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse transfer order not found"); + } + var rows = commands.listTransfer(customerId, transferId); + require(rows != null, "持久实收命令读取失败,请联系管理员核查"); + java.util.Set sources = new java.util.HashSet<>(); + return rows.stream().map(row -> { + require(row != null && Objects.equals(customerId, row.getCustomerId()) && Objects.equals(transferId, row.getTransferOrderId()) + && row.getSourceInventoryPositionId() != null && row.getSourceInventoryPositionId() > 0 + && sources.add(row.getSourceInventoryPositionId()) + && row.getStatus() != null && Set.of("PREPARED", "RESERVED", "COMPLETED").contains(row.getStatus()), + "持久实收命令归属或状态无效,请联系管理员核查"); + var request = readSnapshot(row.getRequestJson(), WarehouseTransferRequests.Receipt.class); + var target = readSnapshot(row.getTargetJson(), Target.class); + require(Objects.equals(request.sourceInventoryPositionId(), row.getSourceInventoryPositionId()) + && text(request.idempotencyKey()) != null && text(request.idempotencyKey()).equals(row.getIdempotencyKey()) + && Objects.equals(hash(request), row.getRequestHash()), "持久实收原请求损坏,请联系管理员核查"); + long warehouseId = positive(target.warehouseId(), "持久实收目标损坏,请联系管理员核查"); + require(target.palletId() != null && target.palletId() > 0 && target.locationId() != null && target.locationId() > 0 + && text(target.warehouseCode()) != null && text(target.warehouseName()) != null + && text(target.palletNo()) != null && text(target.locationCode()) != null && text(target.fbaCode()) != null + && text(target.fbaName()) != null && positive(target.fbaId(), "持久实收目标损坏,请联系管理员核查") > 0 + && Objects.equals(row.getTargetWarehouseId(), warehouseId) + && Objects.equals(row.getTargetPalletId(), target.palletId()) + && Objects.equals(row.getTargetLocationId(), target.locationId()) + && Objects.equals(row.getTargetHash(), hash(new Binding(target.warehouseId(), target.palletId(), target.locationId(), target.fbaCode()))) + && positive(request.destinationOverseasWarehouseId(), "持久实收原请求损坏,请联系管理员核查") == warehouseId + && Objects.equals(request.destinationPalletId(), target.palletId()) + && Objects.equals(text(request.destinationPalletNo()), target.palletNo()) + && Objects.equals(text(request.destinationWarehouseCode()), target.fbaCode()) + && (text(request.destinationWarehouseId()) == null || positive(request.destinationWarehouseId(), + "持久实收原请求损坏,请联系管理员核查") == positive(target.fbaId(), "持久实收目标损坏,请联系管理员核查")) + && (request.warehouseLocationId() == null || Objects.equals(request.warehouseLocationId(), target.locationId())) + && ("PREPARED".equals(row.getStatus()) || row.getResourceLockId() != null && row.getResourceLockId() > 0), + "持久实收目标损坏,请联系管理员核查"); + if ("COMPLETED".equals(row.getStatus())) { + var receipt = receipts.findSource(customerId, transferId, row.getSourceInventoryPositionId()); + require(receipt != null && Objects.equals(receipt.getCustomerId(), customerId) + && Objects.equals(receipt.getTransferOrderId(), transferId) + && Objects.equals(receipt.getRequestHash(), row.getRequestHash()) + && Objects.equals(receipt.getIdempotencyKey(), row.getIdempotencyKey()), + "持久实收完成凭据缺失,请联系管理员核查"); + } + return new WarehouseTransferResponses.ReceiptCommand(row.getSourceInventoryPositionId(), row.getStatus(), + row.getRequestHash(), request, new WarehouseTransferResponses.ReceiptTarget(target.warehouseId(), + target.warehouseCode(), target.warehouseName(), target.palletId(), target.palletNo(), + target.locationId(), target.locationCode(), target.fbaId(), target.fbaCode(), target.fbaName())); + }).toList(); + } + + private T readSnapshot(String payload, Class type) { + try { + com.fasterxml.jackson.databind.JsonNode tree = objectMapper.reader().with(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload); + require(tree != null && tree.isObject(), "持久实收快照损坏,请联系管理员核查"); + var fields = java.util.Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName) + .collect(java.util.stream.Collectors.toSet()); + tree.fieldNames().forEachRemaining(field -> require(fields.contains(field), "持久实收快照含未知字段,请联系管理员核查")); + return objectMapper.treeToValue(tree, type); + } catch (ResponseStatusException failure) { throw failure; } + catch (Exception failure) { throw new ResponseStatusException(HttpStatus.CONFLICT, "持久实收快照损坏,请联系管理员核查"); } + } + public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Receipt request, Supplier replay, Function stockWork) { @@ -99,9 +165,11 @@ public class WarehouseTransferReceiptCoordinator { if (current.replay()) return replay.get(); require("RESERVED".equals(owned.getStatus()) && Objects.equals(owned.getResourceLockId(), lockId), "Receipt has no active reserved resource"); - WarehouseTransferResponses.Detail result = stockWork.apply(new Context(target, lockId, owned.getId())); - owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now()); commands.updateById(owned); - return result; + stockWork.apply(new Context(target, lockId, owned.getId())); + owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now()); + require(commands.updateById(owned) == 1, "Receipt completion changed concurrently"); + // Read inside the same transaction after completion, so the response cannot leak the earlier RESERVED view. + return replay.get(); }); } 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 fef0d4215..dc4ec7b42 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 @@ -601,6 +601,7 @@ public class WarehouseTransferService { transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos, + receiptCoordinator.receiptCommands(customerId, transfer.getId()), transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); } @@ -1129,8 +1130,7 @@ public class WarehouseTransferService { transfer.setAllocationIdempotencyKey(key); transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus( - Boolean.TRUE.equals(transfer.getNeedRelabel()) ? INBOUND_COMPLETED : "WAIT_OUTBOUND"); + String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED); if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW"); } @@ -1203,7 +1203,7 @@ public class WarehouseTransferService { transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); + String before = order.getCurrentStatus(); order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, @@ -1254,12 +1254,16 @@ public class WarehouseTransferService { if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) { throw conflict("Source inventory balance is missing for warehouse transfer receipt"); } + if (sourceBalance.getInboundOrderId() == null || sourceBalance.getInboundOrderId() <= 0 + || !StringUtils.hasText(sourceBalance.getInboundOrderNo()) || !StringUtils.hasText(sourceBalance.getInboundCode())) { + throw conflict("Source inventory inbound identity is missing for warehouse transfer receipt"); + } WarehouseTransferReceiptCoordinator.Target target = context.target(); InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(), target.warehouseId(), target.warehouseCode(), target.warehouseName(), target.fbaId(), target.fbaCode(), target.fbaName(), - transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, + sourceBalance.getInboundOrderId(), sourceBalance.getInboundOrderNo(), sourceBalance.getInboundCode(), Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), target.palletId(), target.palletNo(), @@ -1281,7 +1285,7 @@ public class WarehouseTransferService { || "RECEIVED".equals(value.getStatus())); transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT"); + String before = order.getCurrentStatus(); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED)); order.setUpdatedAt(now); orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, 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 62b0bcf3f..1f6ca180d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1106,7 +1106,7 @@ paths: idempotencyKey: { type: string, maxLength: 255 } remark: { type: string, nullable: true, maxLength: 1000 } responses: - '200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;原键原载荷返回原收货结果 } + '200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;receiptCommands包含真正COMPLETED的本次命令,原键原载荷返回持久结果, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } } '409': { description: 原请求冲突、来源状态或目标资源归属不匹配;不得换键绕过 } '503': { description: 资源服务不可用,保留原收货命令待核对恢复 } /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound: @@ -1193,7 +1193,7 @@ paths: description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。 security: [{ bearerAuth: [] }] parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 当前客户仓储转运单详情 }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 } } + responses: { '200': { description: 当前客户仓储转运单详情,receiptCommands仅本客户本票;未知状态或损坏快照拒绝返回,不当作新请求, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 }, '409': { description: 持久实收命令状态、原请求或目标损坏,须核查原命令 } } /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement: post: summary: 客户确认仓储转运实测数据 @@ -1844,6 +1844,62 @@ paths: responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } components: schemas: + WarehouseTransferDetailApiResponse: + type: object + properties: + data: { $ref: '#/components/schemas/WarehouseTransferDetail' } + WarehouseTransferDetail: + type: object + description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。 + required: [receiptCommands] + properties: + receiptCommands: + type: array + items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' } + WarehouseTransferReceiptCommand: + type: object + additionalProperties: false + required: [sourceInventoryPositionId, status, requestHash, request, target] + properties: + sourceInventoryPositionId: { type: string, description: 原来源库存位置标识,仅用于原命令恢复 } + status: { type: string, enum: [PREPARED, RESERVED, COMPLETED] } + requestHash: { type: string, pattern: '^[a-f0-9]{64}$' } + request: { $ref: '#/components/schemas/WarehouseTransferReceiptRequest' } + target: { $ref: '#/components/schemas/WarehouseTransferReceiptTarget' } + WarehouseTransferReceiptTarget: + type: object + additionalProperties: false + required: [warehouseId, warehouseCode, warehouseName, palletId, palletNo, locationId, locationCode, fbaId, fbaCode, fbaName] + properties: + warehouseId: { type: string } + warehouseCode: { type: string } + warehouseName: { type: string } + palletId: { type: string } + palletNo: { type: string } + locationId: { type: string } + locationCode: { type: string } + fbaId: { type: string } + fbaCode: { type: string } + fbaName: { type: string } + WarehouseTransferReceiptRequest: + type: object + additionalProperties: false + required: [sourceInventoryPositionId, destinationOverseasWarehouseId, destinationWarehouseCode, destinationPalletId, destinationPalletNo, idempotencyKey] + properties: + sourceInventoryPositionId: { type: integer, format: int64 } + destinationOverseasWarehouseId: { type: string, maxLength: 128 } + destinationOverseasWarehouseCode: { type: string, nullable: true, maxLength: 128 } + destinationOverseasWarehouseName: { type: string, nullable: true, maxLength: 255 } + destinationWarehouseId: { type: string, nullable: true, maxLength: 128 } + destinationWarehouseCode: { type: string, maxLength: 128 } + destinationWarehouseName: { type: string, nullable: true, maxLength: 255 } + destinationPalletId: { type: integer, format: int64 } + destinationPalletNo: { type: string, maxLength: 128 } + batchNo: { type: string, nullable: true, maxLength: 128 } + warehouseLocationId: { type: integer, format: int64, nullable: true } + warehouseLocationCode: { type: string, nullable: true, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } ExceptionOrderDetailResponse: type: object properties: 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 a86a0e1ff..5cf993e4a 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 @@ -714,14 +714,14 @@ class RelabelApplicationServiceTest { var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L); transfer.setAllocationIdempotencyKey("ORIGINAL-SOURCE");transfer.setFbaWarehouseCode("ONT8"); - var transferOrder=order(88,"10001","LMO-TRANSFER-1","WAIT_RELABEL"); + var transferOrder=order(88,"10001","LMO-TRANSFER-1","INBOUND_COMPLETED"); when(applications.lockNo("10001","FBR-1")).thenReturn(application);stubDetail(application); when(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer); when(orders.selectById(88L)).thenReturn(transferOrder); var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted"); service.confirmCompletion("10001","FBR-1","customer-1",command); assertEquals("ALLOCATED",transfer.getStatus());assertEquals("WAIT_TRANSFER_OUTBOUND",application.getFulfillmentStatus()); - assertEquals("ALLOCATED",transferOrder.getCurrentStatus()); + assertEquals("INBOUND_COMPLETED",transferOrder.getCurrentStatus()); service.confirmCompletion("10001","FBR-1","customer-1",command); verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any()); verify(outboundOrders,never()).insert(any(OutboundOrder.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java index a37b6e306..8c6b9a3b4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java @@ -11,6 +11,20 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; class WarehouseTransferReceiptOpenApiContractTest { + @Test + @SuppressWarnings("unchecked") + void durableProjectionDocumentsExactPublicRecordsWithoutInternalLocks() { + Map document=new Yaml().load(getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")); + var schemas=(Map)((Map)document.get("components")).get("schemas"); + for(var entry:Map.of("WarehouseTransferReceiptCommand",com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptCommand.class, + "WarehouseTransferReceiptTarget",com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptTarget.class, + "WarehouseTransferReceiptRequest",WarehouseTransferRequests.Receipt.class).entrySet()) { + var properties=(Map)((Map)schemas.get(entry.getKey())).get("properties"); + assertThat(properties.keySet()).isEqualTo(Arrays.stream(entry.getValue().getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName).collect(Collectors.toSet())); + assertThat(properties).doesNotContainKeys("resourceLockId","reservationKey"); + } + } @Test @SuppressWarnings("unchecked") void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() { 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 8030daa98..8a959bfab 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 @@ -115,8 +115,13 @@ class WarehouseTransferReceiptCoordinatorTest { return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST"); } - private void receive(String no, WarehouseTransferRequests.Receipt request) { - coordinator.receive("7", no, "operator", request, () -> null, context -> { + private com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail receive(String no, WarehouseTransferRequests.Receipt request) { + return coordinator.receive("7", no, "operator", request, () -> { + var view = mock(com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail.class); + var head = session.getMapper(WarehouseTransferOrderMapper.class).findNo("7", no); + when(view.receiptCommands()).thenReturn(coordinator.receiptCommands("7", head.getId())); + return view; + }, context -> { assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet(); assertEquals(30L, Long.parseLong(context.target().fbaId())); assertTrue(context.resourceLockId() > 0); jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('STOCK_IN',?)", request.sourceInventoryPositionId()); @@ -160,6 +165,42 @@ class WarehouseTransferReceiptCoordinatorTest { assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class)); verify(warehouse, never()).release(anyString(), anyLong()); } + + @Test void durableProjectionRecoversReservedOriginalPayloadAndSuccessfulResponseIsCompleted() throws Exception { + var original = request(101,20,"KEY-1"); failLocal.set(true); + assertThrows(IllegalStateException.class, () -> receive("WT-1",original)); + var reserved = coordinator.receiptCommands("7",1L); + assertEquals(1,reserved.size()); assertEquals("RESERVED",reserved.get(0).status()); + assertEquals(original,reserved.get(0).request()); assertEquals(hash(original),reserved.get(0).requestHash()); + assertEquals(20L,reserved.get(0).target().palletId()); + assertEquals(reserved,coordinator.receiptCommands("7",1L)); + int before=reserveCalls.get();var success=receive("WT-1",original); + assertEquals("COMPLETED",success.receiptCommands().get(0).status()); + assertEquals(1,occupancies.get());assertEquals(before+1,reserveCalls.get()); + var again=receive("WT-1",original);assertEquals(success.receiptCommands(),again.receiptCommands()); + assertEquals(before+1,reserveCalls.get());assertEquals(1,count("ll_warehouse_transfer_receipt")); + var serialized=json.writeValueAsString(success.receiptCommands()); + assertFalse(serialized.contains("resourceLockId"));assertFalse(serialized.contains("reservationKey")); + } + + @Test void durableProjectionIsCustomerAndTransferScopedAndRejectsCorruptionWithoutRemoteCalls() { + loseReserveResponse.set(true);assertThrows(ResponseStatusException.class,()->receive("WT-1",request(101,20,"KEY-1"))); + assertEquals("PREPARED",coordinator.receiptCommands("7",1L).get(0).status()); + addTransfer(2,"WT-2",102); + assertEquals(List.of(),coordinator.receiptCommands("7",2L)); + assertEquals(404,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("8",1L)).getStatusCode().value()); + int calls=reserveCalls.get(); + String saved=jdbc.queryForObject("SELECT request_json FROM ll_warehouse_transfer_receipt_command WHERE transfer_order_id=1",String.class); + for(String payload:List.of("not-json","{}","{\"sourceInventoryPositionId\":101,\"unknown\":true}")) { + jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET request_json=? WHERE transfer_order_id=1",payload); + assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value()); + } + jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET request_json=?,status='UNKNOWN' WHERE transfer_order_id=1",saved); + assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value()); + jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET status='PREPARED',target_json='{}' WHERE transfer_order_id=1"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value()); + assertEquals(calls,reserveCalls.get());assertEquals(0,count("ll_warehouse_transfer_receipt")); + } @Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() { loseReserveResponse.set(true); var request = request(101, 20, "KEY-1"); assertThrows(ResponseStatusException.class, () -> receive("WT-1", request)); 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 7db6c3cf5..2bb203921 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 @@ -81,6 +81,18 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void detailIncludesOnlyTheCoordinatorScopedDurableReceiptProjection() { + var transfer=transfer(WarehouseTransferService.PARTIALLY_RECEIVED); + when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + var command=new com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptCommand(501L, + "RESERVED","a".repeat(64),receiptRequest(),new com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptTarget( + "900","NY","NY warehouse",801L,"PALLET-DEST",901L,"LOC-DEST","30","ONT8","Amazon ONT8")); + when(receiptCoordinator.receiptCommands("TENANT-A",21L)).thenReturn(List.of(command)); + var detail=service.detail("TENANT-A","WTR-1"); + assertEquals(List.of(command),detail.receiptCommands()); + verify(receiptCoordinator).receiptCommands("TENANT-A",21L); + } @Test void pdaCandidatesUseTheMapperRestrictedToWaitingMeasurementOnly() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); @@ -747,14 +759,25 @@ class WarehouseTransferServiceTest { assertEquals(701L, captured.getValue().getPalletId()); assertEquals(5, captured.getValue().getPackageCount()); assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); - assertEquals("WAIT_OUTBOUND", result.orderStatus()); + assertEquals("INBOUND_COMPLETED", result.orderStatus()); verifyNoInteractions(inventory); + when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(captured.getValue())); + var outbound = service.outbound("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Outbound("OUT-NO-RELABEL", "")); + assertEquals("INBOUND_COMPLETED", outbound.orderStatus()); + assertEquals("WAREHOUSE_TRANSFER:OUTBOUNDED", tailOrder.getSubOrderStatus()); + when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + var received = service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest()); + assertEquals("RECEIVED", received.status()); + assertEquals("INBOUND_COMPLETED", received.orderStatus()); + assertEquals("WAREHOUSE_TRANSFER:RECEIVED", tailOrder.getSubOrderStatus()); } @Test void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); LastlegOrder tailOrder = tailOrder(); + tailOrder.setCurrentStatus("INBOUND_COMPLETED"); WarehouseTransferInventoryAllocation allocation = allocation(); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); @@ -776,6 +799,7 @@ class WarehouseTransferServiceTest { assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5)); assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus()); assertEquals("SHIPPED", allocation.getStatus()); + assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus()); clearInvocations(inventory); service.outbound("TENANT-A", "WTR-1", "USER-1", @@ -793,10 +817,15 @@ class WarehouseTransferServiceTest { assertEquals(5, stockIn.getValue().packages()); assertEquals("Customer A", stockIn.getValue().customerName()); assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode()); + assertEquals(101L,stockIn.getValue().inboundOrderId()); + assertEquals("INBOUND-101",stockIn.getValue().inboundOrderNo()); + assertEquals("IN-1",stockIn.getValue().inboundCode()); + assertTrue(!stockIn.getValue().inboundOrderId().equals(transfer.getLastlegOrderId())); assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); assertEquals("RECEIVED", allocation.getStatus()); + assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus()); ArgumentCaptor receipt = ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class); @@ -807,6 +836,72 @@ class WarehouseTransferServiceTest { verifyNoInteractions(inventory); } + @Test + void partialTransferReceiptKeepsEstablishedMainStateAndOnlyAdvancesItsSubState() { + var transfer = transfer(WarehouseTransferService.OUTBOUNDED); + var tail = tailOrder(); tail.setCurrentStatus("INBOUND_COMPLETED"); + var first = allocation(); first.setStatus("SHIPPED"); + var second = allocation(); second.setId(22L); second.setInventoryPositionId(502L); second.setStatus("SHIPPED"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tail); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(first,second)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601L,101L,"IN-1")); + when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn( + new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false)); + var result=service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); + assertEquals("PARTIALLY_RECEIVED",result.status()); + assertEquals("INBOUND_COMPLETED",result.orderStatus()); + assertEquals("WAREHOUSE_TRANSFER:PARTIALLY_RECEIVED",tail.getSubOrderStatus()); + assertEquals("SHIPPED",second.getStatus()); + } + + @Test + void twoSourcesOnOneTargetPalletKeepTheirDistinctLegalInboundIdentities() { + var transfer=transfer(WarehouseTransferService.OUTBOUNDED); + var tail=tailOrder();tail.setCurrentStatus("INBOUND_COMPLETED"); + var first=allocation();first.setStatus("SHIPPED"); + var second=allocation();second.setId(52L);second.setInventoryPositionId(502L); + second.setInventoryBalanceId(602L);second.setInboundOrderId(202L);second.setInboundCode("IN-2");second.setStatus("SHIPPED"); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tail); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(first,second)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601L,101L,"IN-1")); + when(inventoryCommands.lockBalance("TENANT-A",602L)).thenReturn(balance(602L,202L,"IN-2")); + when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn( + new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false), + new InventoryCommandResponse(2L,"STOCK_IN",802L,902L,5,0,0,0,false,false)); + service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); + var r=receiptRequest(); + service.receive("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Receipt(502L, + r.destinationOverseasWarehouseId(),r.destinationOverseasWarehouseCode(),r.destinationOverseasWarehouseName(), + r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.destinationPalletId(), + r.destinationPalletNo(),r.batchNo(),r.warehouseLocationId(),r.warehouseLocationCode(),"RECEIVE-2",r.remark())); + var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); + verify(inventory,times(2)).stockIn(eq("TENANT-A"),eq("USER-1"),captured.capture()); + assertEquals(List.of(101L,202L),captured.getAllValues().stream().map(value->value.inboundOrderId()).toList()); + assertEquals(List.of("INBOUND-101","INBOUND-202"),captured.getAllValues().stream().map(value->value.inboundOrderNo()).toList()); + assertEquals(List.of("IN-1","IN-2"),captured.getAllValues().stream().map(value->value.inboundCode()).toList()); + assertTrue(captured.getAllValues().stream().allMatch(value->value.palletId().equals(r.destinationPalletId()) + && value.warehouseLocationId().equals(r.warehouseLocationId()) + && value.overseasWarehouseId().equals(r.destinationOverseasWarehouseId()) + && value.destinationWarehouseId().equals(r.destinationWarehouseId())&&value.businessOrderNo().equals("WTR-1"))); + assertEquals("RECEIVED",transfer.getStatus());assertEquals("INBOUND_COMPLETED",tail.getCurrentStatus()); + } + + @Test + void receiptRejectsMissingSourceInboundIdentityInsteadOfSubstitutingTheTransferTail() { + var transfer=transfer(WarehouseTransferService.OUTBOUNDED); + var allocated=allocation();allocated.setStatus("SHIPPED"); + var source=balance(601L,101L,"IN-1");source.setInboundOrderNo(null); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(allocated)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(source); + assertThrows(ResponseStatusException.class,()->service.receive("TENANT-A","WTR-1","USER-1",receiptRequest())); + verifyNoInteractions(inventory);assertEquals("SHIPPED",allocated.getStatus()); + } + @Test void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); @@ -1070,6 +1165,7 @@ class WarehouseTransferServiceTest { private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { InventoryBalance balance = new InventoryBalance(); balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); + balance.setInboundOrderNo("INBOUND-" + inboundOrderId); balance.setCustomerName("Customer A"); balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER"); return balance; -- 2.54.0 From 5b4d9624a95056a35597ef8da5e218671cf9d0e8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 07:24:53 +0800 Subject: [PATCH 162/176] fix(inventory): enforce warehouse transfer reservation ownership --- .../mapper/DispatchCandidateMapper.java | 12 ++- .../mapper/InventoryCommandMapper.java | 9 +++ .../service/InventoryCommandService.java | 79 +++++++++++++++---- .../service/PdaPalletOperationService.java | 42 ++++++++-- .../service/RelabelApplicationService.java | 42 ++++++++-- .../mapper/WarehouseTransferOrderMapper.java | 2 + ...houseTransferRelabelCompletionService.java | 4 +- .../service/WarehouseTransferService.java | 17 ++-- .../DispatchCandidateMapperContractTest.java | 11 +++ .../command/InventoryCommandServiceTest.java | 37 +++++++++ .../PdaPalletOperationServiceTest.java | 36 ++++++++- .../RelabelApplicationServiceTest.java | 2 +- ...rehouseTransferRelabelPersistenceTest.java | 52 +++++++++++- .../service/WarehouseTransferServiceTest.java | 10 +-- 14 files changed, 293 insertions(+), 62 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 1f20c09b1..b676baa75 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 @@ -114,12 +114,10 @@ public interface DispatchCandidateMapper { "d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " + "d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " + "AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " + - // Warehouse-transfer inventory is intentionally reserved before dispatch. It must still be - // eligible for the normal smart-dispatch flow once its destination is bound; other order - // types keep the reservation guard to prevent duplicate dispatching. - "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + // Incoming creation mode never grants ownership of a later transfer reservation. + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + - "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " + + "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + // A warehouse-transfer pallet may contain both relabel and non-relabel inventory. // Do not hide the whole pallet because one carton/SKU has an active relabel item; // the remaining available inventory must still be dispatchable. @@ -213,9 +211,9 @@ public interface DispatchCandidateMapper { "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + - "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + - "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " + + "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + "JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " + "WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index d52314fd8..ffe56f392 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -12,6 +12,15 @@ import java.util.List; @Mapper public interface InventoryCommandMapper { + @Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_application_id=#{applicationId} FOR UPDATE") + Long relabelTransfer(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + record TransferReservation(Long transferOrderId, long packageCount) {} + + @Select("SELECT transfer_order_id,package_count FROM ll_warehouse_transfer_inventory_allocation " + + "WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} AND status='RESERVED' FOR UPDATE") + TransferReservation transferReservation(@Param("customerId") String customerId, @Param("positionId") Long positionId); + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1") InventoryPosition findPosition(@Param("positionId") Long positionId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 77e0528e5..d6c4cb571 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -36,6 +36,7 @@ import java.util.Objects; @Service public class InventoryCommandService { + private record ReservedTransferMove(InventoryCommandRequests.Move move, String sku, Long transferOrderId) {} private record TransferMoveSnapshot(InventoryCommandRequests.Move move, String sku) {} private record TransferAdjustmentSnapshot(InventoryCommandRequests.Adjustment adjustment, String sku) {} @Resource private InventoryBalanceMapper balances; @@ -243,32 +244,68 @@ public class InventoryCommandService { } @Transactional - public InventoryCommandResponse allocateTransferSnapshot(String customerId, String operatorId, - InventoryCommandRequests.Move request, String skuSnapshot) { - requireTransferSku(request.idempotencyKey(), skuSnapshot); - return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request, skuSnapshot); + public InventoryCommandResponse holdWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request, null, + java.util.Objects.requireNonNull(transferOrderId)); } @Transactional - public InventoryCommandResponse pickTransferSnapshot(String customerId, String operatorId, - InventoryCommandRequests.Move request, String skuSnapshot) { - requireTransferSku(request.idempotencyKey(), skuSnapshot); - return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request, skuSnapshot); + public InventoryCommandResponse releaseWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, null, + java.util.Objects.requireNonNull(transferOrderId)); } - /** Exact source snapshot comes from the transfer's locked allocation, not a mixed order aggregate. */ + /** Internal transfer service entry points; no Controller binds this owner argument. */ @Transactional - public InventoryCommandResponse shipTransferSnapshot(String customerId, String operatorId, - InventoryCommandRequests.Move request, String skuSnapshot) { - requireTransferSku(request.idempotencyKey(), skuSnapshot); - return ship(customerId, operatorId, request, skuSnapshot); + public InventoryCommandResponse allocateWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { + if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, + request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId)); + } + + @Transactional + public InventoryCommandResponse pickWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { + if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, + request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId)); + } + + @Transactional + public InventoryCommandResponse shipWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { + if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot); + return ship(customerId, operatorId, request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId)); + } + + private void requireReservation(String customerId, Long positionId, Long owner, long packages, String operationType) { + var reservation = commands.transferReservation(customerId, positionId); + if (reservation == null) { + if (owner != null && !"RELEASE".equals(operationType) && !"HOLD".equals(operationType)) throw conflict("Warehouse transfer reservation is missing"); + return; + } + boolean partial = "RELEASE".equals(operationType) || "HOLD".equals(operationType); + boolean quantityMatches = partial ? packages <= reservation.packageCount() : packages == reservation.packageCount(); + if (owner == null || !owner.equals(reservation.transferOrderId()) || !quantityMatches) { + throw conflict("Inventory is reserved by a warehouse transfer"); + } } private InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request, String skuSnapshot) { + return ship(customerId, operatorId, request, skuSnapshot, null); + } + + private InventoryCommandResponse ship(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot, Long reservationOwner) { return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), - skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { + reservationOwner != null ? new ReservedTransferMove(request, skuSnapshot, reservationOwner) + : skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); + requireReservation(customerId, request.positionId(), reservationOwner, request.packages(), "SHIP"); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked"); @@ -556,10 +593,11 @@ public class InventoryCommandService { } @Transactional - public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId, + public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId, Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { requireTransferSku(request.idempotencyKey(), skuSnapshot); - return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot); + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot, + java.util.Objects.requireNonNull(transferOrderId)); } private static void requireTransferSku(String key, String skuSnapshot) { @@ -569,9 +607,16 @@ public class InventoryCommandService { private InventoryCommandResponse move(String customerId, String operatorId, String operationType, Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot) { + return move(customerId, operatorId, operationType, from, to, request, skuSnapshot, null); + } + + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot, Long reservationOwner) { return execute(customerId, operatorId, operationType, request.idempotencyKey(), - skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { + reservationOwner != null ? new ReservedTransferMove(request, skuSnapshot, reservationOwner) + : skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); + requireReservation(customerId, request.positionId(), reservationOwner, request.packages(), operationType); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase()); 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 8fc1a31bb..1e4cf9624 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 @@ -58,6 +58,7 @@ import java.util.stream.Collectors; @Service public class PdaPalletOperationService { @Resource private PdaPalletOperationMapper mapper; + @Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; @Resource private InventoryLedgerMapper ledgers; @@ -465,14 +466,33 @@ public class PdaPalletOperationService { List transitions = new ArrayList<>(); try { - for (PalletChange change : plan.changes()) { - transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), - change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, - change.pallet().palletId()), reason)); - } - saveTransitions(operation.getId(), transitions); - PalletOperationResponses.Operation result = transaction.execute(status -> - mutate(identity, operation, plan, normalizedKey, requestHash, reason)); + PalletOperationResponses.Operation result = transaction.execute(status -> { + if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); + Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer( + plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId()); + // Allocation also locks the position first: preserve mutual exclusion until commit. + for (PlannedMove move : plan.moves()) { + PalletInventoryRow source = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId()); + if (source == null || !Objects.equals(source.getPalletId(), move.sourcePalletId()) + || value(source.getVersion()) != move.expectedVersion()) throw conflict("Inventory source changed"); + requirePalletReservation(source.getCustomerId(), move.positionId(), + move.bucket() == InventoryBucket.HOLD ? relabelOwner : null); + } + if (plan.relabelMutation() != null) { + for (Long anchorId : plan.relabelMutation().anchors().keySet()) { + PalletInventoryRow anchor = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), anchorId); + if (anchor == null) throw conflict("Relabel inventory anchor disappeared"); + requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner); + } + } + for (PalletChange change : plan.changes()) { + transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), + change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, + change.pallet().palletId()), reason)); + } + saveTransitions(operation.getId(), transitions); + return mutate(identity, operation, plan, normalizedKey, requestHash, reason); + }); if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); return result; } catch (RuntimeException failure) { @@ -482,6 +502,12 @@ public class PdaPalletOperationService { } } + private void requirePalletReservation(String customerId, Long positionId, Long owner) { + var reservation = inventoryCommands.transferReservation(customerId, positionId); + if (reservation != null && (owner == null || !owner.equals(reservation.transferOrderId()))) + throw conflict("Inventory is reserved by a warehouse transfer"); + } + private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation, Plan plan, String key, String requestHash, String reason) { if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); 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 dec53919b..796f0daf1 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 @@ -427,10 +427,10 @@ public class RelabelApplicationService implements WalletResultHandler { int holdIndex = 0; for (SourceInventory source : sources) { if (source.returnCarton() != null) continue; - inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, - "贴标申请冻结原入仓单库存")); + "贴标申请冻结原入仓单库存"), existingTailOrderId, null); } LastlegOrder tailOrder = existingTailOrderId == null ? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now) @@ -681,10 +681,10 @@ public class RelabelApplicationService implements WalletResultHandler { for (RelabelItem item : itemList(application.getId())) { if (item.getSourceInventoryPositionId() == null || Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue; - inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:" - + walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存")); + + walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存"), null, application); } String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion); @@ -739,10 +739,10 @@ public class RelabelApplicationService implements WalletResultHandler { } } else if (item.getSourceInventoryPositionId() != null && Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) > 0) { - inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":RESUBMIT-HOLD:" + holdIndex++, - "换标驳回修改后重新冻结库存")); + "换标驳回修改后重新冻结库存"), null, application); } } application.setLabelDeadline(request.labelDeadline()); @@ -1214,13 +1214,39 @@ public class RelabelApplicationService implements WalletResultHandler { continue; } if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue; - inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( + releaseRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, "RELABEL:" + application.getRelabelNo(), application.getIdempotencyKey() + ":RELEASE:" - + item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单")); + + item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单"), application); } } + private WarehouseTransferOrder relabelInventoryOwner(String customerId, Long existingTailOrderId, + RelabelApplication application) { + if (application != null) { + WarehouseTransferOrder owner = warehouseTransfers.lockByRelabelNo(customerId, application.getRelabelNo()); + if (owner != null && (!Objects.equals(owner.getRelabelApplicationId(), application.getId()) + || !Objects.equals(owner.getLastlegOrderId(), application.getLastlegOrderId()))) + throw conflict("Relabel transfer association changed"); + return owner; + } + return existingTailOrderId == null ? null : warehouseTransfers.lockTailOrder(customerId, existingTailOrderId); + } + + private void holdRelabelInventory(String customerId, String operatorId, InventoryCommandRequests.Move request, + Long existingTailOrderId, RelabelApplication application) { + WarehouseTransferOrder owner = relabelInventoryOwner(customerId, existingTailOrderId, application); + if (owner == null) inventoryCommands.hold(customerId, operatorId, request); + else inventoryCommands.holdWarehouseTransfer(customerId, operatorId, owner.getId(), request); + } + + private void releaseRelabelInventory(String customerId, String operatorId, InventoryCommandRequests.Move request, + RelabelApplication application) { + WarehouseTransferOrder owner = relabelInventoryOwner(customerId, null, application); + if (owner == null) inventoryCommands.release(customerId, operatorId, request); + else inventoryCommands.releaseWarehouseTransfer(customerId, operatorId, owner.getId(), request); + } + private List itemList(Long applicationId) { return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) .orderByAsc(RelabelItem::getId)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index bf0be19c6..86fae596e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper public interface WarehouseTransferOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND lastleg_order_id=#{tailOrderId} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockTailOrder(@Param("customerId") String customerId, @Param("tailOrderId") Long tailOrderId); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java index 5b91f86af..013d8b638 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java @@ -71,7 +71,7 @@ public class WarehouseTransferRelabelCompletionService { if (allocations.countActiveDispatchLock(customer, current.getPalletId()) > 0) throw conflict("本票换标托盘已被排车占用"); String key = "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL:" + item.getId(); - inventory.releaseTransferRelabel(customer, operator, new InventoryCommandRequests.Move(current.getId(), count, + inventory.releaseTransferRelabel(customer, operator, transfer.getId(), new InventoryCommandRequests.Move(current.getId(), count, transfer.getTransferNo(), null, key, key + ":RELEASE", "本票换标客户确认"), item.getSkuCode()); String outputSku = source.getNewSku() == null ? item.getSkuCode() : source.getNewSku(); if (!outputSku.equals(item.getSkuCode())) { @@ -103,7 +103,7 @@ public class WarehouseTransferRelabelCompletionService { if (current == null || balance == null || current.getHoldPackages() == null || current.getHoldPackages() < count) throw conflict("未换标的转运余量不可用"); String key = "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":UNLABELLED:" + row.getId(); - inventory.releaseTransferRelabel(customer, operator, new InventoryCommandRequests.Move(current.getId(), count, + inventory.releaseTransferRelabel(customer, operator, transfer.getId(), new InventoryCommandRequests.Move(current.getId(), count, transfer.getTransferNo(), null, key, key + ":RELEASE", "本票未换标余量确认"), row.getSkuCode()); addTarget(targets, balance, current, count, row.getSkuCode()); } 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 dc4ec7b42..2765f0e2c 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 @@ -1122,7 +1122,7 @@ public class WarehouseTransferService { for (ConfirmedStockSource source : entry.getValue()) { if (source.remainingPackages <= 0) continue; String holdKey = "WAREHOUSE_TRANSFER:" + transferNo + ":UNLABELLED:" + source.allocationId; - inventory.hold(customerId, operatorId, new InventoryCommandRequests.Move(source.positionId, + inventory.holdWarehouseTransfer(customerId, operatorId, transfer.getId(), new InventoryCommandRequests.Move(source.positionId, source.remainingPackages, transferNo, null, holdKey, holdKey + ":HOLD", "本票转运未换标余量预留")); } } @@ -1181,21 +1181,18 @@ public class WarehouseTransferService { InventoryCommandRequests.Move move = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); - if (Boolean.TRUE.equals(transfer.getNeedRelabel())) - inventory.allocateTransferSnapshot(customerId, operatorId, move, allocation.getSkuCode()); - else inventory.allocate(customerId, operatorId, move); + inventory.allocateWarehouseTransfer(customerId, operatorId, transfer.getId(), move, + Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : null); var pick = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark())); - if (Boolean.TRUE.equals(transfer.getNeedRelabel())) - inventory.pickTransferSnapshot(customerId, operatorId, pick, allocation.getSkuCode()); - else inventory.pick(customerId, operatorId, pick); + inventory.pickWarehouseTransfer(customerId, operatorId, transfer.getId(), pick, + Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : null); var ship = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())); - if (Boolean.TRUE.equals(transfer.getNeedRelabel())) - inventory.shipTransferSnapshot(customerId, operatorId, ship, allocation.getSkuCode()); - else inventory.ship(customerId, operatorId, ship); + inventory.shipWarehouseTransfer(customerId, operatorId, transfer.getId(), ship, + Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : null); allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 57c9f8b26..fbfa3203f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -14,6 +14,17 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { + @Test + void historicalTransferCreationModeDoesNotGrantReservationOwnership() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation")); + } + } + @Test void availableAndLockedPalletsApplyTheSameActiveRelabelBoundary() throws Exception { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-relabel-boundary;MODE=MySQL")) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 89733caf6..163039013 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -74,6 +74,43 @@ class InventoryCommandServiceTest { lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of()); } + + @Test + void rejectsDispatchAndRelabelAgainstTransferReservationEvenWithForgedBusinessKey() { + var balance = balance(200L, 1); + var position = new InventoryPosition(); position.setId(300L); position.setBalanceId(200L); + position.setAvailablePackages(1L); + when(commands.lockPosition("100", 300L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + lenient().when(commands.transferReservation("100", 300L)) + .thenReturn(new InventoryCommandMapper.TransferReservation(900L, 1)); + var request = new InventoryCommandRequests.Move(300L, 1, "WT-FORGED", "WT-FORGED", + "WAREHOUSE_TRANSFER_OUTBOUND:WT-FORGED", "WAREHOUSE_TRANSFER:WT-FORGED:ALLOCATE", null); + assertThrows(ResponseStatusException.class, () -> service.allocate("100", "7", request)); + assertThrows(ResponseStatusException.class, () -> service.hold("100", "7", request)); + assertEquals(1L, position.getAvailablePackages()); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); + } + @Test + void onlyExactInternalTransferOwnerCanConsumeReservation() { + var balance = balance(200L, 1); + var position = position(300L, 200L, 1); + when(commands.lockPosition("100", 300L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.transferReservation("100", 300L)).thenReturn(new InventoryCommandMapper.TransferReservation(900L, 1)); + var allocate = new InventoryCommandRequests.Move(300L, 1, "WT-1", "WT-1", "EVENT", "ALLOCATE-1", null); + assertThrows(ResponseStatusException.class, () -> service.allocateWarehouseTransfer("100", "7", 901L, allocate, null)); + service.allocateWarehouseTransfer("100", "7", 900L, allocate, null); + var pick = new InventoryCommandRequests.Move(300L, 1, "WT-1", "WT-1", "EVENT", "PICK-1", null); + service.pickWarehouseTransfer("100", "7", 900L, pick, null); + var ship = new InventoryCommandRequests.Move(300L, 1, "WT-1", "WT-1", "EVENT", "SHIP-1", null); + service.shipWarehouseTransfer("100", "7", 900L, ship, null); + assertEquals(0L, position.getAvailablePackages()); + assertEquals(0L, position.getPickedPackages()); + verify(ledgers, times(3)).insert(any(InventoryLedger.class)); + } + @Test void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception { assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 2541b2c2d..4f955fe2d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -59,6 +59,7 @@ class PdaPalletOperationServiceTest { assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); } @Mock private PdaPalletOperationMapper mapper; + @Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Mock private InventoryPositionMapper positions; @Mock private InventoryBalanceMapper balances; @Mock private InventoryLedgerMapper ledgers; @@ -75,6 +76,7 @@ class PdaPalletOperationServiceTest { @BeforeEach void setUp() { service = new PdaPalletOperationService(); ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); ReflectionTestUtils.setField(service, "ledgers", ledgers); @@ -187,6 +189,35 @@ class PdaPalletOperationServiceTest { assertTrue(result.printableHtml().contains("RL-1")); } + @Test void reservedSourceIsRejectedBeforeAnyRemoteTransition() { + var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, + "OCCUPIED", null, 1); + var target = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, + "IDLE", null, 2); + when(physical.resolve("SOURCE")).thenReturn(source); + when(physical.resolve("TARGET")).thenReturn(target); + when(physical.resolveLocation(88L, target.locationId())).thenReturn( + new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-1010", "STORAGE", "ACTIVE")); + var row = row(100L, 20L, 1L, 5L); + when(mapper.content("88", 20L)).thenReturn(List.of(row)); + when(mapper.content("88", 10L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 20L)).thenReturn(5L); + when(mapper.palletPackages("88", 10L)).thenReturn(0L); + when(mapper.lockPosition("88", 100L)).thenReturn(row); + when(inventoryCommands.transferReservation(row.getCustomerId(), 100L)).thenReturn( + new com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper.TransferReservation(900L, 5)); + AtomicReference operation = new AtomicReference<>(); + doAnswer(call -> { operation.set(call.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(call -> operation.get()); + var request = new PalletOperationRequests.Assemble("reserved", new PalletOperationRequests.PalletExpectation("TARGET", 2), + List.of(new PalletOperationRequests.PalletExpectation("SOURCE", 1)), + List.of(new PalletOperationRequests.MoveLine(100L, 5L, 1L)), "assemble"); + assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request)); + verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any()); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verifyNoInteractions(ledgers); + } + @Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() { var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, "OCCUPIED", null, 1); @@ -203,7 +234,7 @@ class PdaPalletOperationServiceTest { when(mapper.content("88", 10L)).thenReturn(List.of()); when(mapper.palletPackages("88", 20L)).thenReturn(5L); when(mapper.palletPackages("88", 10L)).thenReturn(0L); - when(mapper.lockPosition("88", 100L)).thenReturn(stale); + when(mapper.lockPosition("88", 100L)).thenReturn(planned, stale); AtomicReference operation = new AtomicReference<>(); doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; }) @@ -304,6 +335,9 @@ class PdaPalletOperationServiceTest { doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions) .insert(any(InventoryPosition.class)); PalletInventoryRow held = row(100L, 20L, 3L, 0L); held.setPalletNo("SOURCE"); held.setHoldPackages(2L); + when(inventoryCommands.relabelTransfer(application.getCustomerId(), application.getId())).thenReturn(900L); + when(inventoryCommands.transferReservation(application.getCustomerId(), 100L)).thenReturn( + new com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper.TransferReservation(900L, 2)); when(mapper.content("88", 20L)).thenReturn(List.of(held)); when(mapper.content("88", 10L)).thenReturn(List.of()); when(mapper.lockPosition("88", 100L)).thenReturn(held); 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 5cf993e4a..93fac93d8 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 @@ -828,7 +828,7 @@ class RelabelApplicationServiceTest { assertEquals("SETTLE_PENDING", app.getWalletStatus()); assertEquals(new BigDecimal("0.01"), app.getChargeAmount()); assertEquals("CNY", app.getChargeCurrency()); verify(inventoryCommands, never()).release(any(), any(), any()); - verify(inventoryCommands, never()).releaseTransferRelabel(any(), any(), any(), any()); + verify(inventoryCommands, never()).releaseTransferRelabel(any(), any(), any(), any(), any()); verify(transferRelabelCompletion, never()).complete(any(), any(), any(), any(), any()); verify(histories, times(1)).insert(any(RelabelStatusHistory.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java index 900859a5f..7754cfa93 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java @@ -86,6 +86,50 @@ class WarehouseTransferRelabelPersistenceTest { session.getMapper(WarehouseTransferRelabelBindingMapper.class).insert(binding); } void position(long id,long pallet,long available,long hold,String batch){jdbc.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,pallet_id,pallet_no,batch_no,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES (?,'7',60,'9','ABE4',?,?,?, ?,?,0,0,0,0)",id,pallet,"P-"+pallet,batch,available,hold);} + @Test void linkedRelabelCreationAndReleaseUseRealTransferOwnerAndInventory() { + transfer.setRelabelNo("RL-OWN"); + application.setRelabelNo("RL-OWN"); application.setLastlegOrderId(10L); + session.getMapper(WarehouseTransferOrderMapper.class).insert(transfer); + var relabel=new com.baiyue.lastleg.relabel.service.RelabelApplicationService(); + ReflectionTestUtils.setField(relabel,"warehouseTransfers",session.getMapper(WarehouseTransferOrderMapper.class)); + ReflectionTestUtils.setField(relabel,"inventoryCommands",inventory); + var hold=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(101L,1,"RL-OWN",null, + "RELABEL:RL-OWN","LINKED-HOLD",null); + tx.executeWithoutResult(status->ReflectionTestUtils.invokeMethod(relabel,"holdRelabelInventory", + "7","operator",hold,10L,null)); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)); + var release=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(101L,1,"RL-OWN",null, + "RELABEL:RL-OWN","LINKED-RELEASE",null); + tx.executeWithoutResult(status->ReflectionTestUtils.invokeMethod(relabel,"releaseRelabelInventory", + "7","operator",release,application)); + assertEquals(0L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)); + var retry=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(101L,1,"RL-OWN",null, + "RELABEL:RL-OWN","LINKED-RETRY",null); + tx.executeWithoutResult(status->ReflectionTestUtils.invokeMethod(relabel,"holdRelabelInventory", + "7","operator",retry,null,application)); + assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)); + var independent=new com.baiyue.lastleg.relabel.entity.RelabelApplication(); + independent.setId(999L); independent.setLastlegOrderId(999L); independent.setRelabelNo("RL-OTHER"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->tx.executeWithoutResult(status->ReflectionTestUtils.invokeMethod(relabel,"holdRelabelInventory", + "7","operator",new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(101L,1, + "RL-OTHER",null,"RELABEL:RL-OTHER","OTHER-HOLD",null),null,independent))); + } + + @Test void realReservationBlocksNormalConsumersAndWrongInternalOwner() { + var request=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(101L,1,"WT-ORIGINAL","WT-ORIGINAL", + "WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:FORGED:ALLOCATE",null); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->tx.executeWithoutResult(status->inventory.allocate("7","operator",request))); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->tx.executeWithoutResult(status->inventory.hold("7","operator",request))); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->tx.executeWithoutResult(status->inventory.allocateWarehouseTransfer("7","operator",2L,request,null))); + assertEquals(2L,jdbc.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=101",Long.class)); + assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class)); + assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + } + @Test void confirmsMovedCartonIntoNewSkuAndPreservesOtherHoldWithRealCommandsAndMappers() { tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())); assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class)); @@ -121,12 +165,14 @@ class WarehouseTransferRelabelPersistenceTest { var allocate=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:ALLOCATE",""); var pick=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:PICK",""); var ship=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:SHIP",""); - tx.executeWithoutResult(s->{inventory.allocateTransferSnapshot("7","operator",allocate,actual.getSkuCode());inventory.pickTransferSnapshot("7","operator",pick,actual.getSkuCode());inventory.shipTransferSnapshot("7","operator",ship,actual.getSkuCode());}); - tx.executeWithoutResult(s->{assertTrue(inventory.allocateTransferSnapshot("7","operator",allocate,"NEW").replayed());assertTrue(inventory.pickTransferSnapshot("7","operator",pick,"NEW").replayed());assertTrue(inventory.shipTransferSnapshot("7","operator",ship,"NEW").replayed());}); + tx.executeWithoutResult(s->{inventory.allocateWarehouseTransfer("7","operator",1L,allocate,actual.getSkuCode());inventory.pickWarehouseTransfer("7","operator",1L,pick,actual.getSkuCode());inventory.shipWarehouseTransfer("7","operator",1L,ship,actual.getSkuCode());}); + tx.executeWithoutResult(s->{assertTrue(inventory.allocateWarehouseTransfer("7","operator",1L,allocate,"NEW").replayed());assertTrue(inventory.pickWarehouseTransfer("7","operator",1L,pick,"NEW").replayed());assertTrue(inventory.shipWarehouseTransfer("7","operator",1L,ship,"NEW").replayed());}); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->tx.executeWithoutResult(status->inventory.allocateWarehouseTransfer("7","operator",2L,allocate,"NEW"))); assertEquals(3L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class)); assertEquals(1L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type='SHIP' AND sku_code='NEW'",Long.class)); assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type IN ('SHIP','PICK','ALLOCATE') AND sku_code<>'NEW'",Long.class)); - assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->inventory.shipTransferSnapshot("7","operator",ship,"OLD"))); + assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->inventory.shipWarehouseTransfer("7","operator",1L,ship,"OLD"))); assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class)); } @Test void cartonOnlyConfirmationKeepsSamePositionAndRetainsOriginalBindingHistory(){ 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 2bb203921..a0015ff55 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 @@ -793,9 +793,9 @@ class WarehouseTransferServiceTest { var ordered = inOrder(inventory); ArgumentCaptor move = ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class); - ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture()); - ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture()); - ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture()); + ordered.verify(inventory).allocateWarehouseTransfer(eq("TENANT-A"), eq("USER-1"), anyLong(), move.capture(), org.mockito.ArgumentMatchers.isNull()); + ordered.verify(inventory).pickWarehouseTransfer(eq("TENANT-A"), eq("USER-1"), anyLong(), move.capture(), org.mockito.ArgumentMatchers.isNull()); + ordered.verify(inventory).shipWarehouseTransfer(eq("TENANT-A"), eq("USER-1"), anyLong(), move.capture(), org.mockito.ArgumentMatchers.isNull()); assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5)); assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus()); assertEquals("SHIPPED", allocation.getStatus()); @@ -958,7 +958,7 @@ class WarehouseTransferServiceTest { when(sourceOrders.selectList(any())).thenReturn(List.of()); service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed")); service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed")); - verify(inventory).shipTransferSnapshot(eq("TENANT-A"),eq("USER-1"),any(),eq("NEW")); + verify(inventory).shipWarehouseTransfer(eq("TENANT-A"),eq("USER-1"),anyLong(),any(),eq("NEW")); verify(inventory,never()).ship(anyString(),anyString(),any());assertEquals("OUTBOUNDED",app.getFulfillmentStatus()); var mixed=balance(601,101,"IN-1");mixed.setSkuCode("MIXED");when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(mixed); when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn(new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false)); @@ -984,7 +984,7 @@ class WarehouseTransferServiceTest { when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation)); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient")) - .when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any()); + .when(inventory).pickWarehouseTransfer(eq("TENANT-A"), eq("USER-1"), anyLong(), any(), org.mockito.ArgumentMatchers.isNull()); assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Outbound("OUT-1", null))); -- 2.54.0 From 5864e6244387bd7f88fb16bc22cadd69a6068014 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 07:38:43 +0800 Subject: [PATCH 163/176] test(lastleg): include transfer reservations in hold concurrency fixture --- .../command/InventoryHoldConcurrencyPersistenceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java index 27e4c0ba8..b08b236c2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -59,7 +59,8 @@ class InventoryHoldConcurrencyPersistenceTest { for (Class entity : List.of(InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class, LastlegOrder.class, LastlegOrderCommandRecord.class, LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, - ReturnStockReceipt.class, DispatchPdaPickOrder.class)) { + ReturnStockReceipt.class, DispatchPdaPickOrder.class, + com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation.class)) { String columns = Arrays.stream(entity.getDeclaredFields()) .filter(f -> !Modifier.isStatic(f.getModifiers())) .filter(f -> f.getAnnotation(TableField.class) == null || f.getAnnotation(TableField.class).exist()) -- 2.54.0 From 113d2b1fec3c233487eb1a4edeb7063cf1e160ca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 08:10:43 +0800 Subject: [PATCH 164/176] fix(discard): recover completed PDA confirmation without duplicate writes --- .../pda/service/PdaCargoDiscardService.java | 30 ++++- .../service/PdaCargoDiscardServiceTest.java | 122 ++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java 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 7ffd2a9b5..b496842bb 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 @@ -50,7 +50,10 @@ public class PdaCargoDiscardService { CargoDiscardApplication application = applications.findNoByWarehouse(discardNo, String.valueOf(identity.overseasWarehouseId())); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); - if (!CargoDiscardService.APPROVED.equals(application.getStatus())) throw conflict("废弃任务当前不可进行 PDA 作业"); + boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus()) + || CargoDiscardService.COMPLETED.equals(application.getStatus()); + if (!CargoDiscardService.APPROVED.equals(application.getStatus()) && !terminal) + throw conflict("废弃任务当前不可进行 PDA 作业"); PdaCargoDiscardDtos.PalletTask task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())) .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); Map actuals = request.lines().stream().collect(Collectors.toMap(PdaCargoDiscardDtos.ActualLine::itemId, @@ -58,11 +61,29 @@ public class PdaCargoDiscardService { if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); } - LocalDateTime now = LocalDateTime.now(); + List palletItems = new ArrayList<>(); for (PdaCargoDiscardDtos.Sku line : task.skus()) { CargoDiscardItem item = items.selectById(line.itemId()); + if (item == null || !application.getCustomerId().equals(item.getCustomerId()) + || !Objects.equals(application.getId(), item.getApplicationId()) + || !Objects.equals(task.profileId(), isReturnItem(item) ? item.getReturnCartonId() : item.getProfileId())) + throw conflict("废弃明细不属于当前申请或托盘"); + if ("COMPLETED".equals(item.getPdaStatus()) + && !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) + throw conflict("已完成托盘的实际数量不能修改"); + palletItems.add(item); + } + if (terminal && items.listApplication(application.getCustomerId(), application.getId()).stream() + .anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus()) + || !application.getCustomerId().equals(item.getCustomerId()) + || !Objects.equals(application.getId(), item.getApplicationId()))) + throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); + // 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("废弃终态不能执行未完成托盘"); + LocalDateTime now = LocalDateTime.now(); + for (CargoDiscardItem item : palletItems) { if ("COMPLETED".equals(item.getPdaStatus())) { - if (!Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) throw conflict("已完成托盘的实际数量不能修改"); continue; } List links = cartons.listApplication(application.getCustomerId(), application.getId()).stream() @@ -84,7 +105,8 @@ public class PdaCargoDiscardService { discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认"); } - return task; + return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); } private List palletTasks(CargoDiscardApplication application) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java new file mode 100644 index 000000000..6bbed447b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -0,0 +1,122 @@ +package com.baiyue.lastleg.discard.pda.service; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +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 org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PdaCargoDiscardServiceTest { + private final CargoDiscardApplicationMapper applications = mock(CargoDiscardApplicationMapper.class); + private final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class); + private final CargoDiscardCartonMapper cartons = mock(CargoDiscardCartonMapper.class); + private final DropshipOrderProfileMapper profiles = mock(DropshipOrderProfileMapper.class); + private final CargoDiscardInventoryAdjustmentService adjustments = mock(CargoDiscardInventoryAdjustmentService.class); + private final CargoDiscardService discardService = mock(CargoDiscardService.class); + private final ReturnInventoryCartonMapper returnCartons = mock(ReturnInventoryCartonMapper.class); + private PdaCargoDiscardService service; + private CargoDiscardApplication application; + private CargoDiscardItem item; + private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L); + private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual))); } + + @BeforeEach void setup() { + service = new PdaCargoDiscardService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "profiles", profiles); + ReflectionTestUtils.setField(service, "adjustments", adjustments); + ReflectionTestUtils.setField(service, "discardService", discardService); + ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1"); + application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99"); + application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00")); + item = new CargoDiscardItem(); item.setId(11L); item.setApplicationId(1L); item.setCustomerId("CUSTOMER"); + item.setProfileId(22L); item.setSkuCode("SKU"); item.setDiscardPackages(1); item.setPdaStatus("PENDING"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(22L); profile.setCustomerId("CUSTOMER"); profile.setPalletNo("PALLET-1"); + when(applications.findNoByWarehouse("CD-1", "99")).thenReturn(application); + when(items.listApplication("CUSTOMER", 1L)).thenAnswer(invocation -> List.of(item)); + when(items.selectById(11L)).thenReturn(item); + when(profiles.selectById(22L)).thenReturn(profile); + when(cartons.listApplication("CUSTOMER", 1L)).thenReturn(List.of()); + doAnswer(invocation -> { application.setStatus(invocation.getArgument(3)); return null; }) + .when(discardService).updateLinkedStatus(anyString(), same(application), anyString(), anyString(), anyString(), anyString()); + } + private void finished(String status) { + application.setStatus(status); item.setPdaStatus("COMPLETED"); item.setActualDiscardPackages(1); + item.setPdaOperatorId("original"); item.setPdaCompletedAt(LocalDateTime.of(2026, 10, 5, 1, 2)); + } + private void noWrites() { + verifyNoInteractions(adjustments, discardService); + verify(items, never()).updateById(any(CargoDiscardItem.class)); + verify(applications, never()).updateById(any(CargoDiscardApplication.class)); + } + @Test void lastPalletActualConfirmationReplaysAfterPdaCompletionWithoutDeductionOrHistoryAgain() { + var first = service.confirm(identity, "CD-1", "PALLET-1", request(1)); + assertEquals("COMPLETED", first.status()); + assertEquals(1, first.skus().get(0).actualPackages()); + assertEquals("COMPLETED", first.skus().get(0).status()); + assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); + var completedAt = item.getPdaCompletedAt(); var result = service.confirm(identity, "CD-1", "PALLET-1", request(1)); + assertEquals("COMPLETED", result.status()); assertEquals(1, result.skus().get(0).actualPackages()); + assertEquals(completedAt, item.getPdaCompletedAt()); + verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(1), eq("OP"), any()); + verify(items, times(1)).updateById(item); + verify(discardService, times(1)).updateLinkedStatus(anyString(), same(application), anyString(), anyString(), anyString(), anyString()); + } + @ParameterizedTest @ValueSource(strings = {"PDA_COMPLETED", "COMPLETED", "APPROVED"}) + void exactFinishedPalletIsReadOnlyInAllowedStates(String status) { + finished(status); var result = service.confirm(identity, "CD-1", "PALLET-1", request(1)); + assertEquals("COMPLETED", result.status()); assertEquals(status, application.getStatus()); noWrites(); + } + @ParameterizedTest @ValueSource(strings = {"PDA_COMPLETED", "COMPLETED"}) + void finishedQuantityCannotChange(String status) { finished(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(0))); noWrites(); } + @Test void missingOrForeignLineAndWrongPalletCannotRecover() { + finished("PDA_COMPLETED"); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of()))); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(999L, 1))))); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "OTHER-PALLET", request(1))); noWrites(); + } + @Test void foreignWarehouseHasNoApplication() { + finished("PDA_COMPLETED"); assertThrows(ResponseStatusException.class, () -> service.confirm(new PdaWarehouseIdentity("OP", "operator", 100L), "CD-1", "PALLET-1", request(1))); noWrites(); + } + @Test void foreignCustomerPhysicalPalletCannotRecover() { + finished("PDA_COMPLETED"); DropshipOrderProfile other = new DropshipOrderProfile(); other.setCustomerId("OTHER"); other.setPalletNo("PALLET-1"); + when(profiles.selectById(22L)).thenReturn(other); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); + } + @ParameterizedTest @ValueSource(strings = {"CANCELLED", "REJECTED", "PENDING_REVIEW"}) + void cancelledRejectedOrUnapprovedCannotPretendSuccess(String status) { finished(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } + @ParameterizedTest @ValueSource(strings = {"PDA_COMPLETED", "COMPLETED"}) + void unfinishedItemInTerminalApplicationCannotRecover(String status) { application.setStatus(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } + @Test void terminalApplicationWithAnotherUnfinishedPalletCannotRecover() { + finished("PDA_COMPLETED"); CargoDiscardItem pending = new CargoDiscardItem(); pending.setId(12L); pending.setApplicationId(1L); + pending.setCustomerId("CUSTOMER"); pending.setProfileId(23L); pending.setSkuCode("OTHER-SKU"); pending.setDiscardPackages(1); pending.setPdaStatus("PENDING"); + DropshipOrderProfile other = new DropshipOrderProfile(); other.setId(23L); other.setCustomerId("CUSTOMER"); other.setPalletNo("PALLET-2"); + when(items.listApplication("CUSTOMER", 1L)).thenReturn(List.of(item, pending)); when(profiles.selectById(23L)).thenReturn(other); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); + } + @Test void duplicateLineCannotRecover() { finished("COMPLETED"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1), new PdaCargoDiscardDtos.ActualLine(11L, 1))))); noWrites(); } + @Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } + @Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } +} -- 2.54.0 From 7577650eee6e721b865212a2b8c5d70034812f6d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 11:51:47 +0800 Subject: [PATCH 165/176] feat(fleet): read attributable trip receivables and receipts from finance --- .../InternalTripFinanceController.java | 31 +++ .../baiyue/finance/dto/TripFinanceModels.java | 17 ++ .../service/TripFinanceQueryService.java | 85 +++++++ .../openapi/finance-trip-receivables-v1.yaml | 234 ++++++++++++++++++ .../InternalTripFinanceControllerTest.java | 23 ++ .../service/TripFinanceQueryServiceTest.java | 68 +++++ .../PlatformTripOperationsController.java | 8 +- .../fleet/reporting/TripFinanceClient.java | 19 ++ .../TripOperationsFinanceService.java | 85 +++++++ .../openapi/fleet-operations-v1.yaml | 222 ++++++++++++++++- .../TripOperationsFinanceServiceTest.java | 101 ++++++++ 11 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java create mode 100644 finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java create mode 100644 finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml create mode 100644 finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java new file mode 100644 index 000000000..8666ed8d7 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java @@ -0,0 +1,31 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.TripFinanceModels; +import com.baiyue.finance.service.TripFinanceQueryService; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/finance/v1/trip-receivables") +public class InternalTripFinanceController { + private final TripFinanceQueryService service; + private final String expectedToken; + public InternalTripFinanceController(TripFinanceQueryService service, + @Value("${baiyue.security.internal-service-token}") String expectedToken) { + this.service=service;this.expectedToken=expectedToken; + } + @PostMapping("/query") + public ApiResponse query( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @Valid @RequestBody TripFinanceModels.Query query) { + if(token==null || expectedToken==null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),expectedToken.getBytes(StandardCharsets.UTF_8))) + throw new FinanceEventException("FIN-EVT-401","内部服务令牌无效"); + return FinanceResponses.success(service.query(query)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java b/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java new file mode 100644 index 000000000..79e1becb2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java @@ -0,0 +1,17 @@ +package com.baiyue.finance.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.util.List; + +public final class TripFinanceModels { + private TripFinanceModels() { } + public record Business(@Positive long customerId, @NotBlank @Size(max=64) String deliveryNo) { } + public record Query(@NotNull @Size(max=200) List<@Valid Business> businesses, boolean receiptsVisible) { } + public record Money(String currency, BigDecimal receivableAmount, BigDecimal receivedAmount) { } + public record Source(long customerId, String businessNo, String sourceNo, String feeItemName, int sourceVersion, + String currency, BigDecimal amount, String status) { } + public record Document(String billNo, String currency, BigDecimal receivedAmount, String allocationStatus, String reason) { } + public record Result(String status, List totals, List sources, List documents, String reason) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java new file mode 100644 index 000000000..e889a9458 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java @@ -0,0 +1,85 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.TripFinanceModels; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.*; + +/** Only whole documents whose complete source set belongs to this trip are attributable. No ratios. */ +@Service +public class TripFinanceQueryService { + private final JdbcTemplate jdbc; + public TripFinanceQueryService(JdbcTemplate jdbc) {this.jdbc=jdbc;} + record Fee(long id,long customerId,String businessNo,String sourceNo,String name,int version,String currency,BigDecimal amount,String status) { } + record Bill(long id,long customerId,String no,String currency) { } + @Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public TripFinanceModels.Result query(TripFinanceModels.Query request) { + if(request.businesses().isEmpty())return new TripFinanceModels.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"尚无可明确归属车次的派送费用来源"); + var args=new HashMap();var predicates=new ArrayList(); + int index=0; + for(var b:request.businesses().stream().distinct().toList()) { + predicates.add("(f.customer_id=:customer"+index+" AND f.business_no=:business"+index+")"); + args.put("customer"+index,b.customerId());args.put("business"+index,b.deliveryNo());index++; + } + var fees=new NamedParameterJdbcTemplate(jdbc).query(""" + SELECT f.id,f.customer_id,f.business_no,f.source_no,f.fee_item_name,f.source_fee_version,f.currency_code,f.amount,f.status + FROM fin_fee_source f WHERE f.source_system='LASTLEG' AND f.business_type='DELIVERY_ORDER' + AND f.source_type='OVERSEAS_DELIVERY_FEE' AND f.direction='RECEIVABLE' + AND f.review_status='APPROVED' AND f.status IN ('AVAILABLE','SETTLED') + AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer WHERE newer.source_system=f.source_system + AND newer.source_type=f.source_type AND newer.source_fee_id=f.source_fee_id + AND newer.source_fee_version>f.source_fee_version) + AND ( + """+String.join(" OR ",predicates)+") ORDER BY f.customer_id,f.business_no,f.source_fee_id LIMIT 2001", + args,(r,i)->new Fee(r.getLong("id"),r.getLong("customer_id"),r.getString("business_no"),r.getString("source_no"),r.getString("fee_item_name"),r.getInt("source_fee_version"),r.getString("currency_code"),r.getBigDecimal("amount"),r.getString("status"))); + if(fees.size()>2000)throw new FinanceEventException("FIN_400","车次费用来源超过2000条,请核对来源范围"); + return project(fees,request); + } + TripFinanceModels.Result project(List fees,TripFinanceModels.Query request) { + var receivables=new TreeMap();var received=new TreeMap(); + var pending=new HashSet();var docs=new ArrayList(); + var eligible=new HashMap();fees.forEach(f->{eligible.put(f.id(),f);receivables.merge(f.currency(),f.amount(),BigDecimal::add);}); + boolean missing=request.businesses().stream().anyMatch(b->fees.stream().noneMatch(f->f.customerId()==b.customerId()&&f.businessNo().equals(b.deliveryNo()))); + if(request.receiptsVisible()&&!fees.isEmpty()) { + var named=new NamedParameterJdbcTemplate(jdbc); + var refs=named.query(""" + SELECT b.id,b.tenant_id,b.bill_no,b.currency_code,l.source_id FROM fin_bill_line l + JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id + JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id AND f.source_fee_version=l.source_version + WHERE l.source_id IN (:ids) AND b.status='ISSUED' + """,Map.of("ids",eligible.keySet()),(r,i)->new Object[]{ + new Bill(r.getLong("id"),r.getLong("tenant_id"),r.getString("bill_no"),r.getString("currency_code")),r.getLong("source_id")}); + var bills=new LinkedHashMap();var byFee=new HashMap>(); + for(var ref:refs){var bill=(Bill)ref[0];bills.put(bill.id(),bill);byFee.computeIfAbsent((Long)ref[1],k->new HashSet<>()).add(bill.id());} + for(var fee:fees)if(byFee.getOrDefault(fee.id(),Set.of()).size()!=1)pending.add(fee.currency()); + if(!bills.isEmpty()) { + var lines=named.query("SELECT bill_id,tenant_id,source_id,source_version,currency_code FROM fin_bill_line WHERE bill_id IN (:ids)", + Map.of("ids",bills.keySet()),(r,i)->new Object[]{r.getLong("bill_id"),r.getLong("tenant_id"),r.getLong("source_id"),r.getInt("source_version"),r.getString("currency_code")}); + var byBill=new HashMap>();for(var line:lines)byBill.computeIfAbsent((Long)line[0],k->new ArrayList<>()).add(line); + var payments=named.query(""" + SELECT rb.bill_id,r.currency_code,SUM(rb.applied_amount) amount FROM fin_receipt_bill rb + JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id + JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id AND b.currency_code=r.currency_code + WHERE rb.bill_id IN (:ids) AND r.status='CONFIRMED' GROUP BY rb.bill_id,r.currency_code + """,Map.of("ids",bills.keySet()),(r,i)->new Object[]{r.getLong("bill_id"),r.getString("currency_code"),r.getBigDecimal("amount")}); + var paidByBill=new HashMap();for(var payment:payments)paidByBill.put((Long)payment[0],(BigDecimal)payment[2]); + for(var bill:bills.values()) { + var billLines=byBill.getOrDefault(bill.id(),List.of()); + boolean exact=!billLines.isEmpty()&&billLines.stream().allMatch(l->{var f=eligible.get((Long)l[2]);return f!=null&&f.customerId()==bill.customerId()&&(Long)l[1]==bill.customerId()&&f.version()==(Integer)l[3]&&f.currency().equals(bill.currency())&&f.currency().equals(l[4]);}); + BigDecimal paid=exact?paidByBill.get(bill.id()):null; + String reason=!exact?"账单包含本车次以外或无法明确归属的来源,实收待分配":paid==null?"尚无已确认的本车次收款分配":"整张账单的全部来源明确归属本车次"; + if(paid==null)pending.add(bill.currency());else received.merge(bill.currency(),paid,BigDecimal::add); + docs.add(new TripFinanceModels.Document(bill.no(),bill.currency(),paid,paid==null?"PENDING_ALLOCATION":"ATTRIBUTED",reason)); + } + } + } + var totals=receivables.entrySet().stream().map(e->new TripFinanceModels.Money(e.getKey(),e.getValue(), + !request.receiptsVisible()||pending.contains(e.getKey())?null:received.get(e.getKey()))).toList(); + String status=fees.isEmpty()||missing?"PENDING_ALLOCATION":"AVAILABLE"; + String reason=missing?"部分派送尚无已审核有效的Finance来源,已确认金额不代表完整车次应收":"仅包含已审核有效的Finance来源;实收只计明确归属本车次的已确认分配"; + return new TripFinanceModels.Result(status,totals,fees.stream().map(f->new TripFinanceModels.Source(f.customerId(),f.businessNo(),f.sourceNo(),f.name(),f.version(),f.currency(),f.amount(),f.status())).toList(),List.copyOf(docs),reason); + } +} diff --git a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml new file mode 100644 index 000000000..5b15dabc6 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml @@ -0,0 +1,234 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "车次Finance权威应收与实收只读来源", + "version": "1.0.0", + "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收,按币种。实收只取fin_receipt_bill已确认分配;整张ISSUED账单全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" + }, + "paths": { + "/internal/finance/v1/trip-receivables/query": { + "post": { + "security": [ + { + "internalToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "businesses", + "receiptsVisible" + ], + "properties": { + "businesses": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": [ + "customerId", + "deliveryNo" + ], + "properties": { + "customerId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "deliveryNo": { + "type": "string", + "maxLength": 64 + } + } + } + }, + "receiptsVisible": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Finance来源及归属,不生成费用/账单/分配", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TripFinanceAuthority" + } + } + } + } + } + }, + "400": { + "description": "输入或来源数量超限" + }, + "401": { + "description": "内部令牌无效" + } + } + } + } + }, + "components": { + "securitySchemes": { + "internalToken": { + "type": "apiKey", + "in": "header", + "name": "X-Internal-Service-Token" + } + }, + "schemas": { + "TripFinanceMoney": { + "type": "object", + "required": [ + "currency", + "receivableAmount", + "receivedAmount" + ], + "properties": { + "currency": { + "type": "string" + }, + "receivableAmount": { + "type": "number", + "minimum": 0 + }, + "receivedAmount": { + "type": "number", + "minimum": 0, + "nullable": true + } + } + }, + "TripFinanceSource": { + "type": "object", + "required": [ + "customerId", + "businessNo", + "sourceNo", + "feeItemName", + "sourceVersion", + "currency", + "amount", + "status" + ], + "properties": { + "customerId": { + "type": "string" + }, + "businessNo": { + "type": "string" + }, + "sourceNo": { + "type": "string", + "nullable": true + }, + "feeItemName": { + "type": "string" + }, + "sourceVersion": { + "type": "integer", + "minimum": 1 + }, + "currency": { + "type": "string" + }, + "amount": { + "type": "number", + "minimum": 0 + }, + "status": { + "type": "string" + } + } + }, + "TripFinanceDocument": { + "type": "object", + "required": [ + "billNo", + "currency", + "receivedAmount", + "allocationStatus", + "reason" + ], + "properties": { + "billNo": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "receivedAmount": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "allocationStatus": { + "type": "string", + "enum": [ + "ATTRIBUTED", + "PENDING_ALLOCATION" + ] + }, + "reason": { + "type": "string" + } + } + }, + "TripFinanceAuthority": { + "type": "object", + "required": [ + "status", + "totals", + "sources", + "documents", + "reason" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "AVAILABLE", + "PENDING_ALLOCATION" + ] + }, + "totals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceMoney" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceSource" + } + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceDocument" + } + }, + "reason": { + "type": "string" + } + } + } + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java new file mode 100644 index 000000000..58d137d07 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.controller; +import com.baiyue.finance.dto.TripFinanceModels; +import com.baiyue.finance.service.TripFinanceQueryService; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class InternalTripFinanceControllerTest { + @Test void rejectsAbsentOrWrongInternalTokenBeforeReadingFinance() { + var service=mock(TripFinanceQueryService.class);var controller=new InternalTripFinanceController(service,"synthetic-token"); + var query=new TripFinanceModels.Query(List.of(),false); + assertThatThrownBy(()->controller.query(null,query)).isInstanceOf(FinanceEventException.class); + assertThatThrownBy(()->controller.query("wrong",query)).isInstanceOf(FinanceEventException.class); + verifyNoInteractions(service); + } + @Test void validInternalTokenReturnsOnlyTheProviderResult() { + var service=mock(TripFinanceQueryService.class);var query=new TripFinanceModels.Query(List.of(),false); + var result=new TripFinanceModels.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"pending"); + when(service.query(query)).thenReturn(result); + assertThat(new InternalTripFinanceController(service,"synthetic-token").query("synthetic-token",query).data()).isSameAs(result); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java new file mode 100644 index 000000000..b7601d48d --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java @@ -0,0 +1,68 @@ +package com.baiyue.finance.service; +import com.baiyue.finance.dto.TripFinanceModels; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.*; +class TripFinanceQueryServiceTest { + JdbcTemplate jdbc;TripFinanceQueryService service; + @BeforeEach void setup() { + jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT,customer_id BIGINT,business_no VARCHAR(64),business_type VARCHAR(32),source_no VARCHAR(64),fee_item_name VARCHAR(64),source_fee_id BIGINT,source_fee_version INT,source_system VARCHAR(32),source_type VARCHAR(32),direction VARCHAR(32),review_status VARCHAR(32),status VARCHAR(32),currency_code VARCHAR(3),amount DECIMAL(20,6))"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,bill_no VARCHAR(64),currency_code VARCHAR(3),status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_bill_line(tenant_id BIGINT,bill_id BIGINT,source_id BIGINT,source_version INT,currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_receipt(id BIGINT,tenant_id BIGINT,status VARCHAR(16),currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_receipt_bill(tenant_id BIGINT,receipt_id BIGINT,bill_id BIGINT,applied_amount DECIMAL(20,6))"); + service=new TripFinanceQueryService(jdbc); + } + void fee(long id,long customer,String business,int version,String currency,String amount,String status) { + jdbc.update("INSERT INTO fin_fee_source VALUES(?,?,?,'DELIVERY_ORDER',?,'Delivery',99,?,'LASTLEG','OVERSEAS_DELIVERY_FEE','RECEIVABLE','APPROVED',?,?,?)",id,customer,business,business,version,status,currency,new BigDecimal(amount)); + } + TripFinanceModels.Query request(boolean receipts) {return new TripFinanceModels.Query(List.of(new TripFinanceModels.Business(1,"DL-A")),receipts);} + void bill(long source,int version) { + jdbc.update("INSERT INTO fin_bill VALUES(1,1,'BILL-A','CNY','ISSUED')"); + jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,?,?,'CNY')",source,version); + } + void receipt(String amount) { + jdbc.update("INSERT INTO fin_receipt VALUES(1,1,'CONFIRMED','CNY')"); + jdbc.update("INSERT INTO fin_receipt_bill VALUES(1,1,1,?)",new BigDecimal(amount)); + } + @Test void exactWholeBillUsesRecordedAllocationAmount() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("3.5"); + var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("3.5"); + assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + } + @Test void mixedBillNeverProratesReceipt() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("30"); + jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,999,1,'CNY')"); + var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isNull(); + assertThat(result.documents().get(0).billNo()).isEqualTo("BILL-A"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); + } + @Test void unbilledOrNoConfirmedReceiptIsUnknownNotZero() { + fee(1,1,"DL-A",1,"CNY","0","AVAILABLE"); + assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); + bill(1,1);assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); + assertThat(service.query(request(true)).totals().get(0).receivableAmount()).isEqualByComparingTo("0"); + } + @Test void latestVoidedDoesNotFallBackAndOtherCustomerExcluded() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");fee(2,1,"DL-A",2,"CNY","20","VOIDED"); + fee(3,2,"DL-A",1,"CNY","999","AVAILABLE"); + var result=service.query(request(true));assertThat(result.sources()).isEmpty();assertThat(result.status()).isEqualTo("PENDING_ALLOCATION"); + } + @Test void currenciesSeparateAndNoReceiptPermissionMasksDocuments() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE"); + jdbc.update("UPDATE fin_fee_source SET source_fee_id=1 WHERE id=1"); + fee(2,1,"DL-A",1,"USD","5","AVAILABLE"); + var result=service.query(request(false));assertThat(result.totals()).extracting(TripFinanceModels.Money::currency).containsExactly("CNY","USD"); + assertThat(result.totals()).allSatisfy(m->assertThat(m.receivedAmount()).isNull());assertThat(result.documents()).isEmpty(); + } + @Test void wholeTripBillIsCountedOnceAcrossTwoFeeLines() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");jdbc.update("UPDATE fin_fee_source SET source_fee_id=1 WHERE id=1"); + fee(2,1,"DL-A",1,"CNY","10","AVAILABLE");bill(1,1);jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,2,1,'CNY')");receipt("7"); + var result=service.query(request(true));assertThat(result.documents()).hasSize(1);assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("7"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java index db01842bf..b0e86d9ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java @@ -13,7 +13,8 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/platform/v1/last-leg/fleet/operations") public class PlatformTripOperationsController { private final TripOperationsService service; - public PlatformTripOperationsController(TripOperationsService service) { this.service=service; } + private final TripOperationsFinanceService finance; + public PlatformTripOperationsController(TripOperationsService service,TripOperationsFinanceService finance) { this.service=service;this.finance=finance; } @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") public ApiResponse report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); } @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')") @@ -21,5 +22,10 @@ public class PlatformTripOperationsController { return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) .header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user))); } + @GetMapping("/{tripNo}/finance") + @PreAuthorize("hasAuthority('platform:fleet:operations:list') and hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse finance(@PathVariable String tripNo,@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(finance.read(tripNo,user!=null&&user.permissions().contains("platform:finance:receipt:view"))); + } private static boolean canReadCosts(AuthenticatedUser user){return user!=null&&user.permissions().contains("platform:fleet:trip:list");} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java new file mode 100644 index 000000000..97e7cebba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; +import java.util.List; + +@FeignClient(name="finance-service",contextId="tripFinanceClient",url="${baiyue.clients.finance.url:}") +public interface TripFinanceClient { + @PostMapping("/internal/finance/v1/trip-receivables/query") + ApiResponse query(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Query query); + record Business(long customerId,String deliveryNo) { } + record Query(List businesses,boolean receiptsVisible) { } + record Money(String currency,BigDecimal receivableAmount,BigDecimal receivedAmount) { } + record Source(String customerId,String businessNo,String sourceNo,String feeItemName,int sourceVersion,String currency,BigDecimal amount,String status) { } + record Document(String billNo,String currency,BigDecimal receivedAmount,String allocationStatus,String reason) { } + record Result(String status,List totals,List sources,List documents,String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java new file mode 100644 index 000000000..bab41f757 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.lastleg.fleet.trip.FleetTripModels; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +@Service +public class TripOperationsFinanceService { + private final JdbcTemplate jdbc;private final ObjectMapper json;private final TripFinanceClient finance;private final String token; + public TripOperationsFinanceService(JdbcTemplate jdbc,ObjectMapper json,TripFinanceClient finance, + @Value("${baiyue.security.internal-service-token}") String token) {this.jdbc=jdbc;this.json=json;this.finance=finance;this.token=token;} + public record Result(String tripNo,List dispatchNos,boolean receiptsVisible,TripFinanceClient.Result finance,String attributionStatus,String attributionReason) { } + record Binding(String customerId,String dispatchNo) { } + record Ownership(Binding binding,long tripCount,String tripNo) { } + record Delivery(Binding binding,String deliveryNo) { } + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public Result read(String tripNo,boolean receiptsVisible) { + var snapshots=jdbc.query(""" + SELECT h.snapshot_json FROM ll_fleet_trip t JOIN ll_fleet_trip_history h ON h.trip_id=t.id AND h.action='DEPART' + WHERE t.trip_no=? AND t.departed_at IS NOT NULL ORDER BY h.record_version LIMIT 1 + """,(r,i)->r.getString(1),tripNo); + if(snapshots.size()!=1)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"实际发车记录不存在"); + FleetTripModels.AuditSnapshot snapshot; + try {snapshot=json.readValue(snapshots.get(0),FleetTripModels.AuditSnapshot.class);} + catch(Exception e){throw new IllegalStateException("实际发车财务归属快照无法读取",e);} + if(snapshot.trip()==null||!tripNo.equals(snapshot.trip().tripNo())||snapshot.confirmedLoad()==null||snapshot.confirmedLoad().cargo()==null) + throw new IllegalStateException("实际发车快照缺少车次或货物归属"); + var bindings=snapshot.confirmedLoad().cargo().stream().map(c->new Binding(c.customerId(),c.dispatchNo())).distinct().toList(); + if(bindings.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次来源超过200组,请核对来源"); + var businesses=new ArrayList();boolean pending=bindings.isEmpty(); + if(!bindings.isEmpty()) { + var args=new ArrayList();var ownerPairs=new ArrayList();var deliveryPairs=new ArrayList(); + for(var binding:bindings) { + ownerPairs.add("(c.customer_id=? AND c.dispatch_no=?)"); + deliveryPairs.add("(customer_id=? AND dispatch_no=?)"); + args.add(binding.customerId());args.add(binding.dispatchNo()); + } + // One scan of the global immutable DEPART set, including outside report filters. + var owners=jdbc.query(""" + SELECT c.customer_id,c.dispatch_no,COUNT(DISTINCT t.trip_no) trip_count,MAX(t.trip_no) trip_no FROM ll_fleet_trip t + JOIN ll_fleet_trip_history h ON h.trip_id=t.id AND h.action='DEPART' + JOIN JSON_TABLE(h.snapshot_json,'$.confirmedLoad.cargo[*]' COLUMNS( + customer_id VARCHAR(32) PATH '$.customerId',dispatch_no VARCHAR(64) PATH '$.dispatchNo')) c + WHERE t.departed_at IS NOT NULL AND ( + """+String.join(" OR ",ownerPairs)+") GROUP BY c.customer_id,c.dispatch_no", + (r,i)->new Ownership(new Binding(r.getString("customer_id"),r.getString("dispatch_no")),r.getLong("trip_count"),r.getString("trip_no")),args.toArray()); + var deliveries=jdbc.query("SELECT customer_id,dispatch_no,delivery_no FROM ll_delivery_order WHERE (" + +String.join(" OR ",deliveryPairs)+") ORDER BY customer_id,dispatch_no,delivery_no LIMIT 201", + (r,i)->new Delivery(new Binding(r.getString("customer_id"),r.getString("dispatch_no")),r.getString("delivery_no")),args.toArray()); + if(deliveries.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次派送来源超过200条,请核对来源"); + var ownersByBinding=new HashMap(); + for(var owner:owners)if(ownersByBinding.put(owner.binding(),owner)!=null)throw new IllegalStateException("车次来源归属汇总重复"); + var deliveriesByBinding=new HashMap>(); + for(var delivery:deliveries)deliveriesByBinding.computeIfAbsent(delivery.binding(),k->new ArrayList<>()).add(delivery.deliveryNo()); + for(var binding:bindings) { + var owner=ownersByBinding.get(binding); + var numbers=deliveriesByBinding.getOrDefault(binding,List.of()); + if(owner==null||owner.tripCount()!=1||!tripNo.equals(owner.tripNo())||numbers.isEmpty()){pending=true;continue;} + for(var no:numbers)businesses.add(new TripFinanceClient.Business(Long.parseLong(binding.customerId()),no)); + } + } + if(businesses.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次派送来源超过200条,请核对来源"); + TripFinanceClient.Result result; + try { + var response=finance.query(token,new TripFinanceClient.Query(businesses.stream().distinct().toList(),receiptsVisible)); + if(response==null||!response.successful()||response.data()==null)throw new IllegalStateException("财务响应缺失"); + result=response.data();validate(result,businesses,receiptsVisible); + }catch(Exception e){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Finance车次应收/实收暂不可查询",e);} + return new Result(tripNo,bindings.stream().map(Binding::dispatchNo).distinct().toList(),receiptsVisible,result, + pending?"PENDING_ALLOCATION":"ATTRIBUTED",pending?"部分来源无法唯一归属本车次,完整金额待分配":"按不可变发车客户及排车来源核对派送费用"); + } + static void validate(TripFinanceClient.Result value,List businesses,boolean receiptsVisible) { + if(value.totals()==null||value.sources()==null||value.documents()==null||!Set.of("AVAILABLE","PENDING_ALLOCATION").contains(value.status()))throw new IllegalStateException("财务归属响应无效"); + var currencies=new HashSet(); + for(var money:value.totals())if(money.currency()==null||!money.currency().matches("[A-Z]{3}")||!currencies.add(money.currency())||money.receivableAmount()==null||money.receivableAmount().signum()<0||money.receivedAmount()!=null&&money.receivedAmount().signum()<0||!receiptsVisible&&money.receivedAmount()!=null)throw new IllegalStateException("财务金额响应无效"); + for(var source:value.sources())if(businesses.stream().noneMatch(b->Long.toString(b.customerId()).equals(source.customerId())&&b.deliveryNo().equals(source.businessNo()))||source.sourceVersion()<1||source.amount()==null||source.amount().signum()<0)throw new IllegalStateException("财务来源超出车次"); + if(!receiptsVisible&&!value.documents().isEmpty())throw new IllegalStateException("无实收权限却返回收款来源"); + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index aa4fb9ffa..9c464547d 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -3,7 +3,7 @@ "info": { "title": "真实出车与自有外接运营统计", "version": "1.0.0", - "description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;财务未查询明确NOT_QUERIED;成本只通过FleetTripCostService.summaries(Collection)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" + "description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" }, "security": [ { @@ -589,6 +589,51 @@ } } } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/finance": { + "get": { + "description": "operations:list与platform:finance:customer-arap:list同时要求;实收及收款单据另要求platform:finance:receipt:view,否则receiptsVisible=false、实收null、documents空。按需一次跨服务读取,无列表逐行HTTP;不写状态/费用/分配。", + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64 + } + } + ], + "responses": { + "200": { + "description": "同车次财务归属结果;已确认部分与完整金额待分配分别标明", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TripFinance" + } + } + } + } + } + }, + "403": { + "description": "缺运营或应收权限" + }, + "404": { + "description": "无实际发车记录" + }, + "503": { + "description": "Finance不可查询,不能替换成零" + } + } + } } }, "components": { @@ -1102,6 +1147,181 @@ "maximum": 10485760 } } + }, + "TripFinanceMoney": { + "type": "object", + "required": [ + "currency", + "receivableAmount", + "receivedAmount" + ], + "properties": { + "currency": { + "type": "string" + }, + "receivableAmount": { + "type": "number", + "minimum": 0 + }, + "receivedAmount": { + "type": "number", + "minimum": 0, + "nullable": true + } + } + }, + "TripFinanceSource": { + "type": "object", + "required": [ + "customerId", + "businessNo", + "sourceNo", + "feeItemName", + "sourceVersion", + "currency", + "amount", + "status" + ], + "properties": { + "customerId": { + "type": "string" + }, + "businessNo": { + "type": "string" + }, + "sourceNo": { + "type": "string", + "nullable": true + }, + "feeItemName": { + "type": "string" + }, + "sourceVersion": { + "type": "integer", + "minimum": 1 + }, + "currency": { + "type": "string" + }, + "amount": { + "type": "number", + "minimum": 0 + }, + "status": { + "type": "string" + } + } + }, + "TripFinanceDocument": { + "type": "object", + "required": [ + "billNo", + "currency", + "receivedAmount", + "allocationStatus", + "reason" + ], + "properties": { + "billNo": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "receivedAmount": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "allocationStatus": { + "type": "string", + "enum": [ + "ATTRIBUTED", + "PENDING_ALLOCATION" + ] + }, + "reason": { + "type": "string" + } + } + }, + "TripFinanceAuthority": { + "type": "object", + "required": [ + "status", + "totals", + "sources", + "documents", + "reason" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "AVAILABLE", + "PENDING_ALLOCATION" + ] + }, + "totals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceMoney" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceSource" + } + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceDocument" + } + }, + "reason": { + "type": "string" + } + } + }, + "TripFinance": { + "type": "object", + "required": [ + "tripNo", + "dispatchNos", + "receiptsVisible", + "finance", + "attributionStatus", + "attributionReason" + ], + "properties": { + "tripNo": { + "type": "string" + }, + "dispatchNos": { + "type": "array", + "items": { + "type": "string" + } + }, + "receiptsVisible": { + "type": "boolean" + }, + "finance": { + "$ref": "#/components/schemas/TripFinanceAuthority" + }, + "attributionStatus": { + "type": "string", + "enum": [ + "ATTRIBUTED", + "PENDING_ALLOCATION" + ] + }, + "attributionReason": { + "type": "string" + } + } } } }, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java new file mode 100644 index 000000000..3bea7c9c8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.fleet.reporting; +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.security.access.prepost.PreAuthorize; +import java.util.*; +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class TripOperationsFinanceServiceTest { + static class Reads extends JdbcTemplate { + final Queue> results=new ArrayDeque<>();final List sql=new ArrayList<>(); + @Override @SuppressWarnings("unchecked") public List query(String statement,RowMapper mapper,Object...args){sql.add(statement);return (List)results.remove();} + } + static TripOperationsFinanceService.Ownership owner(String customer,String dispatch,String trip) {return new TripOperationsFinanceService.Ownership(new TripOperationsFinanceService.Binding(customer,dispatch),1,trip);} + static TripOperationsFinanceService.Delivery delivery(String customer,String dispatch,String no) {return new TripOperationsFinanceService.Delivery(new TripOperationsFinanceService.Binding(customer,dispatch),no);} + TripFinanceClient.Result value() {return new TripFinanceClient.Result("AVAILABLE",List.of(new TripFinanceClient.Money("CNY",new BigDecimal("20"),null)),List.of(new TripFinanceClient.Source("1","DL-A","FEE-A","Delivery",1,"CNY",new BigDecimal("20"),"AVAILABLE")),List.of(),"actual source");} + @Test void readsFrozenCustomerDispatchThenFinanceWithoutCrossDatabase() { + var jdbc=new Reads();jdbc.results.add(List.of(TripOperationsServiceTest.DEPARTURE)); + jdbc.results.add(List.of(owner("1","D-A","TR-A"),owner("2","D-B","TR-A"))); + jdbc.results.add(List.of(delivery("1","D-A","DL-A"),delivery("2","D-B","DL-B"))); + var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(value())); + var service=new TripOperationsFinanceService(jdbc,new ObjectMapper().registerModule(new JavaTimeModule()),client,"test-token"); + var result=service.read("TR-A",false);assertThat(result.attributionStatus()).isEqualTo("ATTRIBUTED"); + verify(client).query("test-token",new TripFinanceClient.Query(List.of(new TripFinanceClient.Business(1,"DL-A"),new TripFinanceClient.Business(2,"DL-B")),false)); + assertThat(jdbc.sql).allSatisfy(sql->assertThat(sql).doesNotContain("fin_fee_source","fin_receipt")); + } + @Test void dispatchUsedByAnotherDepartureIsNotAssignedAgain() { + var jdbc=new Reads();jdbc.results.add(List.of(TripOperationsServiceTest.DEPARTURE)); + jdbc.results.add(List.of(new TripOperationsFinanceService.Ownership(new TripOperationsFinanceService.Binding("1","D-A"),2,"TR-OTHER"),new TripOperationsFinanceService.Ownership(new TripOperationsFinanceService.Binding("2","D-B"),2,"TR-OTHER"))); + jdbc.results.add(List.of(delivery("1","D-A","DL-A"),delivery("2","D-B","DL-B"))); + var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(new TripFinanceClient.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"pending"))); + var result=new TripOperationsFinanceService(jdbc,new ObjectMapper().registerModule(new JavaTimeModule()),client,"test-token").read("TR-A",true); + assertThat(result.attributionStatus()).isEqualTo("PENDING_ALLOCATION");verify(client).query("test-token",new TripFinanceClient.Query(List.of(),true)); + } + @Test void databaseQueryCountIsConstantForOneTwoAndTwoHundredBindings() throws Exception { + for(int count:List.of(1,2,200)) { + var json=new ObjectMapper().registerModule(new JavaTimeModule()); + var snapshot=json.readTree(TripOperationsServiceTest.DEPARTURE);var cargo=json.createArrayNode(); + var owners=new ArrayList();var deliveries=new ArrayList(); + var expected=new ArrayList(); + for(int i=1;i<=count;i++) { + var item=json.createObjectNode().put("customerId",Integer.toString(i)).put("dispatchNo","D-"+i); + cargo.add(item);owners.add(owner(Integer.toString(i),"D-"+i,"TR-A")); + deliveries.add(delivery(Integer.toString(i),"D-"+i,"DL-"+i));expected.add(new TripFinanceClient.Business(i,"DL-"+i)); + } + ((com.fasterxml.jackson.databind.node.ObjectNode)snapshot.get("confirmedLoad")).set("cargo",cargo); + var jdbc=new Reads();jdbc.results.add(List.of(json.writeValueAsString(snapshot)));jdbc.results.add(owners);jdbc.results.add(deliveries); + var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(new TripFinanceClient.Result("AVAILABLE",List.of(),List.of(),List.of(),"authority"))); + var result=new TripOperationsFinanceService(jdbc,json,client,"test-token").read("TR-A",false); + assertThat(result.attributionStatus()).isEqualTo("ATTRIBUTED");assertThat(jdbc.sql).hasSize(3);assertThat(jdbc.results).isEmpty(); + verify(client,times(1)).query("test-token",new TripFinanceClient.Query(expected,false)); + } + } + @Test void batchDeliverySqlExecutesExactPairsWithoutCartesianCrossCustomerLeak() { + var data=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + class DatabaseReads extends JdbcTemplate { + int queries; + DatabaseReads(){super(data);} + @Override @SuppressWarnings("unchecked") public List query(String statement,RowMapper mapper,Object...args) { + queries++; + if(statement.contains("JSON_TABLE")) { + assertThat(args).containsExactly("1","D-A","2","D-B"); + return (List)List.of(owner("1","D-A","TR-A"),owner("2","D-B","TR-A")); + } + return super.query(statement,mapper,args); + } + } + var jdbc=new DatabaseReads(); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR(64),departed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,action VARCHAR(32),record_version BIGINT,snapshot_json VARCHAR(10000))"); + jdbc.execute("CREATE TABLE ll_delivery_order(customer_id VARCHAR(32),dispatch_no VARCHAR(64),delivery_no VARCHAR(64))"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-A',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,'DEPART',1,?)",TripOperationsServiceTest.DEPARTURE); + jdbc.update("INSERT INTO ll_delivery_order VALUES('1','D-A','DL-A'),('2','D-B','DL-B'),('1','D-B','WRONG-CUSTOMER-A'),('2','D-A','WRONG-CUSTOMER-B')"); + var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(value())); + new TripOperationsFinanceService(jdbc,new ObjectMapper().registerModule(new JavaTimeModule()),client,"test-token").read("TR-A",false); + assertThat(jdbc.queries).isEqualTo(3); + verify(client).query("test-token",new TripFinanceClient.Query(List.of(new TripFinanceClient.Business(1,"DL-A"),new TripFinanceClient.Business(2,"DL-B")),false)); + } + @Test void moreThanTwoHundredBindingsStopsBeforeBatchReadsOrFinance() throws Exception { + var json=new ObjectMapper().registerModule(new JavaTimeModule());var snapshot=json.readTree(TripOperationsServiceTest.DEPARTURE);var cargo=json.createArrayNode(); + for(int i=1;i<=201;i++)cargo.add(json.createObjectNode().put("customerId",Integer.toString(i)).put("dispatchNo","D-"+i)); + ((com.fasterxml.jackson.databind.node.ObjectNode)snapshot.get("confirmedLoad")).set("cargo",cargo); + var jdbc=new Reads();jdbc.results.add(List.of(json.writeValueAsString(snapshot)));var client=mock(TripFinanceClient.class); + assertThatThrownBy(()->new TripOperationsFinanceService(jdbc,json,client,"test-token").read("TR-A",false)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThat(jdbc.sql).hasSize(1);verifyNoInteractions(client); + } + @Test void mismatchedCustomerAndUnauthorizedReceiptAreRejected() { + assertThatThrownBy(()->TripOperationsFinanceService.validate(value(),List.of(new TripFinanceClient.Business(2,"DL-A")),false)).isInstanceOf(IllegalStateException.class); + var withReceipt=new TripFinanceClient.Result("AVAILABLE",List.of(new TripFinanceClient.Money("CNY",BigDecimal.ONE,BigDecimal.ONE)),List.of(),List.of(),"actual"); + assertThatThrownBy(()->TripOperationsFinanceService.validate(withReceipt,List.of(),false)).isInstanceOf(IllegalStateException.class); + } + @Test void financeEndpointRequiresOperationsAndExistingReceivablePermission() throws Exception { + var method=Arrays.stream(PlatformTripOperationsController.class.getMethods()).filter(m->m.getName().equals("finance")).findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:fleet:operations:list","platform:finance:customer-arap:list"); + } +} -- 2.54.0 From 0c26e1e99dad6a85d74de10d72614213ee1dfb5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 12:00:09 +0800 Subject: [PATCH 166/176] test(discard): expect approval to reserve stock until PDA confirmation --- .../phase4-overseas-client-services.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 36868b0ec..813d9455b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -349,8 +349,9 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro proof.approvedNo = approvedNo await review(approvedNo,'APPROVE') const approved = await discardSnapshot(context,token,proof,approvedNo); proof.snapshots.approved = approved - const available = snapshot => snapshot.balances.reduce((sum,row)=>sum+Number(row.availablePackages),0) - require('正式废弃合同要求审批事务扣减一箱', approved.detail?.summary?.status === 'APPROVED' && available(base)-available(approved) === 1) + require('批准只保留锁箱且库存数量流水不变', approved.detail?.summary?.status === 'APPROVED' + && sameStock(base, approved) + && Number(approved.source?.availablePackages || 0) === Number(base.source.availablePackages) - 1) await review(approvedNo,'APPROVE') require('重复批准不会提前扣库存或新增历史', stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved)) const reverse = await context.request('POST',query(`${root}/${encodeURIComponent(approvedNo)}/review`,{customerId:proof.customerId}),{decision:'REJECT',remark:context.marker},context.token,{}) @@ -365,7 +366,7 @@ export async function run(context) { context.createdResources.phase4OverseasClientServices = { marker: context.marker, relabelNo: null, transferNo: null, discardNo: null, finalStatuses: { relabel: null, transfer: null, discard: null }, - cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval-transaction deduction checked; no outbound, dispatch or delivery action is invoked', + cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval lock checked; actual stock deduction requires separate PDA confirmation; no outbound, dispatch or delivery action is invoked', } const token = await loginMerchant(context) if (!token) return -- 2.54.0 From 8ac24000438669922c7e4fd2dcbbfdad63ed2b64 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 12:07:02 +0800 Subject: [PATCH 167/176] docs(discard): align confirmation semantics and current coordination checks --- lastleg-service/AGENTS.md | 4 ++-- scripts/agents-index-contract.ps1 | 16 ++++++++-------- scripts/agents-index-registry-contract.test.mjs | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 3424cec54..98c3ab0e1 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -50,7 +50,7 @@ | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | -| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java` | +| 货物废弃、待审锁箱、实际确认扣减 | `discard/controller/CargoDiscardController.java` | | 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` | | 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` | @@ -141,7 +141,7 @@ - `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 -- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 +- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 - 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 81a8449e2..4ba170505 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -201,20 +201,20 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务 Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' # BEGIN CURRENT TEST REGISTRY CONTRACT function Assert-TestTaskRegistry([string]$registry) { - $current = ($registry -split '(?m)^## 2026-10-03 归档与重新规划')[0] - Assert-Contains $current '## 2026-10-03 车辆管理四任务协作(当前)' '当前车辆协作入口' - Assert-Contains $current '确认创建三个任务,我们当前目标调整一下,加上自己,一共4个任务' '测试任务精确数量授权' - Assert-Contains $current '车辆总控:当前对话 `01a0f19d-3f0b-7ae0-a860-c0d1d2e84475`' '测试所属总控' + $current = ($registry -split '(?m)^## 2026-10-04 全流程唯一持续目标与车辆交接(当前)')[0] + Assert-Contains $current '## 2026-10-04 用户确认的三个独立执行对话(当前派工)' '当前全流程协作入口' + Assert-Contains $current '用户明确选择“按这 3 个独立对话创建”' '测试任务精确数量授权' + Assert-Contains $current '所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`' '测试所属总控' Assert-Contains $current '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试保存项目' Assert-Contains $current '`C:/Users/BY-OY/Desktop/code/chongchong-global`' '测试工作目录' - Assert-Contains $current '不混入开发环境' '测试授权环境边界' - Assert-Contains $current '其他旧任务仍保持归档,不自动恢复' '测试任务禁止历史恢复' - $expectedIds = @('01a0ffcf-7150-7db3-9d8c-108e568c12b1','01a0ffcf-eea3-7111-b30b-4be63547ac33','01a0ffd0-2a4d-7210-bad8-a790cd965e8a') + Assert-Contains $current 'host `local` 与 cwd' '测试授权环境边界' + Assert-Contains $current '旧对话保持原状态,不恢复或唤醒' '测试任务禁止历史恢复' + $expectedIds = @('01a10686-c55c-75a3-9639-655b50cbce6c','01a10686-cae9-7943-b976-27e78ad92723','01a10686-cfdd-7f50-8500-7704e6fbc768') $actualIds = @([regex]::Matches($current, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) $script:checked++ if ($actualIds.Count -ne 3 -or @($actualIds | Select-Object -Unique).Count -ne 3 -or @($expectedIds | Where-Object { $actualIds -notcontains $_ }).Count -gt 0) { - Add-Failure '测试当前登记必须恰为三个独立任务,加车辆总控共四个任务' + Add-Failure '测试当前登记必须恰为三个已授权独立任务,归属于唯一全流程总控' } Assert-NotContains $registry '当前无登记任务' '测试登记不得混入过期清空状态' Assert-NotContains $registry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' diff --git a/scripts/agents-index-registry-contract.test.mjs b/scripts/agents-index-registry-contract.test.mjs index 7ac952cca..3e009b82f 100644 --- a/scripts/agents-index-registry-contract.test.mjs +++ b/scripts/agents-index-registry-contract.test.mjs @@ -23,12 +23,12 @@ test('actual explicitly authorized TEST registry passes the production validator assert.deepEqual(failures(registry), []) }) for (const [name, mutate] of [ - ['missing creation authorization', s => s.replace('确认创建三个任务,我们当前目标调整一下,加上自己,一共4个任务', '安排任务')], - ['wrong controller', s => s.replace('车辆总控:当前对话 `01a0f19d-3f0b-7ae0-a860-c0d1d2e84475`', 'task_id=unknown')], + ['missing creation authorization', s => s.replace('用户明确选择“按这 3 个独立对话创建”', '安排任务')], + ['wrong controller', s => s.replace('所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`', 'task_id=unknown')], ['wrong project', s => s.replace('`c64729ff-6710-43c6-8ef2-d84e9666cc1e`', 'projectId=wrong')], ['wrong workspace', s => s.replace('`C:/Users/BY-OY/Desktop/code/chongchong-global`', '`C:/Users/BY-OY/Desktop/code/chongchong-global-dev`')], - ['duplicate task', s => s.replace('01a0ffcf-eea3-7111-b30b-4be63547ac33', '01a0ffcf-7150-7db3-9d8c-108e568c12b1')], - ['unregistered replacement', s => s.replace('01a0ffcf-eea3-7111-b30b-4be63547ac33', '00000000-0000-0000-0000-000000000000')], + ['duplicate task', s => s.replace('01a10686-cae9-7943-b976-27e78ad92723', '01a10686-c55c-75a3-9639-655b50cbce6c')], + ['unregistered replacement', s => s.replace('01a10686-cae9-7943-b976-27e78ad92723', '00000000-0000-0000-0000-000000000000')], ['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'], -- 2.54.0 From f13d3406209fff6db148a53986302035883b5ce0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 12:22:18 +0800 Subject: [PATCH 168/176] fix(finance): retain trip receipts after partial and full payment --- .../service/TripFinanceQueryService.java | 2 +- .../openapi/finance-trip-receivables-v1.yaml | 2 +- .../service/TripFinanceQueryServiceTest.java | 31 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java index e889a9458..197d70a00 100644 --- a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java @@ -49,7 +49,7 @@ public class TripFinanceQueryService { SELECT b.id,b.tenant_id,b.bill_no,b.currency_code,l.source_id FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id AND f.source_fee_version=l.source_version - WHERE l.source_id IN (:ids) AND b.status='ISSUED' + WHERE l.source_id IN (:ids) AND b.status IN ('ISSUED','PARTIALLY_PAID','PAID') """,Map.of("ids",eligible.keySet()),(r,i)->new Object[]{ new Bill(r.getLong("id"),r.getLong("tenant_id"),r.getString("bill_no"),r.getString("currency_code")),r.getLong("source_id")}); var bills=new LinkedHashMap();var byFee=new HashMap>(); diff --git a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml index 5b15dabc6..5ff65b8c9 100644 --- a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml +++ b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml @@ -3,7 +3,7 @@ "info": { "title": "车次Finance权威应收与实收只读来源", "version": "1.0.0", - "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收,按币种。实收只取fin_receipt_bill已确认分配;整张ISSUED账单全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" + "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收,按币种。实收只取fin_receipt_bill已确认分配;整张已出账账单(ISSUED、PARTIALLY_PAID、PAID)全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" }, "paths": { "/internal/finance/v1/trip-receivables/query": { diff --git a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java index b7601d48d..47e4c6ea3 100644 --- a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java @@ -35,17 +35,46 @@ class TripFinanceQueryServiceTest { assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); } + @Test void issuedPartialAndPaidBillsKeepTheirConfirmedAllocation() { + for(String status:List.of("ISSUED","PARTIALLY_PAID","PAID")) { + setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1); + String amount="PAID".equals(status)?"20":"3.5";receipt(amount); + jdbc.update("UPDATE fin_bill SET status=?",status); + var result=service.query(request(true)); + assertThat(result.documents()).as(status).hasSize(1); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + assertThat(result.totals().get(0).receivedAmount()).as(status).isEqualByComparingTo(amount); + } + } + @Test void draftPendingApprovedAndVoidedBillsNeverContributeReceipts() { + for(String status:List.of("DRAFT","PENDING_REVIEW","APPROVED","VOIDED")) { + setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("3.5"); + jdbc.update("UPDATE fin_bill SET status=?",status); + var result=service.query(request(true));assertThat(result.documents()).as(status).isEmpty(); + assertThat(result.totals().get(0).receivedAmount()).isNull(); + } + } + @Test void paidBillStillEnforcesCustomerVersionAndCurrencyAttribution() { + for(String change:List.of("source_version=2","tenant_id=2","currency_code='USD'")) { + setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("20"); + jdbc.update("UPDATE fin_bill SET status='PAID'");jdbc.update("UPDATE fin_bill_line SET "+change); + assertThat(service.query(request(true)).totals().get(0).receivedAmount()).as(change).isNull(); + } + } @Test void mixedBillNeverProratesReceipt() { fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("30"); jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,999,1,'CNY')"); + jdbc.update("UPDATE fin_bill SET status='PAID'"); var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isNull(); + assertThat(result.documents()).hasSize(1); assertThat(result.documents().get(0).billNo()).isEqualTo("BILL-A"); assertThat(result.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); } @Test void unbilledOrNoConfirmedReceiptIsUnknownNotZero() { fee(1,1,"DL-A",1,"CNY","0","AVAILABLE"); assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); - bill(1,1);assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); + bill(1,1);var unpaid=service.query(request(true));assertThat(unpaid.totals().get(0).receivedAmount()).isNull(); + assertThat(unpaid.documents()).hasSize(1);assertThat(unpaid.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); assertThat(service.query(request(true)).totals().get(0).receivableAmount()).isEqualByComparingTo("0"); } @Test void latestVoidedDoesNotFallBackAndOtherCustomerExcluded() { -- 2.54.0 From adaac7aca429e32591bad193b2ab25e5f6688038 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 12:35:38 +0800 Subject: [PATCH 169/176] fix(finance): include billed sources in trip financial attribution --- .../service/TripFinanceQueryService.java | 2 +- .../openapi/finance-trip-receivables-v1.yaml | 2 +- .../service/TripFinanceQueryServiceTest.java | 33 +++++++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java index 197d70a00..4ebf610ea 100644 --- a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java @@ -28,7 +28,7 @@ public class TripFinanceQueryService { SELECT f.id,f.customer_id,f.business_no,f.source_no,f.fee_item_name,f.source_fee_version,f.currency_code,f.amount,f.status FROM fin_fee_source f WHERE f.source_system='LASTLEG' AND f.business_type='DELIVERY_ORDER' AND f.source_type='OVERSEAS_DELIVERY_FEE' AND f.direction='RECEIVABLE' - AND f.review_status='APPROVED' AND f.status IN ('AVAILABLE','SETTLED') + AND f.review_status='APPROVED' AND f.status IN ('AVAILABLE','BILLED','SETTLED') AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version) diff --git a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml index 5ff65b8c9..cb41ba568 100644 --- a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml +++ b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml @@ -3,7 +3,7 @@ "info": { "title": "车次Finance权威应收与实收只读来源", "version": "1.0.0", - "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收,按币种。实收只取fin_receipt_bill已确认分配;整张已出账账单(ISSUED、PARTIALLY_PAID、PAID)全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" + "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收(AVAILABLE、正式出账后的BILLED、SETTLED;LOCKED、VOIDED、SUPERSEDED排除),按币种。实收只取fin_receipt_bill已确认分配;整张已出账账单(ISSUED、PARTIALLY_PAID、PAID)全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" }, "paths": { "/internal/finance/v1/trip-receivables/query": { diff --git a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java index 47e4c6ea3..c0fe30027 100644 --- a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java @@ -24,6 +24,8 @@ class TripFinanceQueryServiceTest { void bill(long source,int version) { jdbc.update("INSERT INTO fin_bill VALUES(1,1,'BILL-A','CNY','ISSUED')"); jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,?,?,'CNY')",source,version); + // Fixture models the real ISSUE source state; this does not call the write service. + jdbc.update("UPDATE fin_fee_source SET status='BILLED' WHERE id=? AND status='AVAILABLE'",source); } void receipt(String amount) { jdbc.update("INSERT INTO fin_receipt VALUES(1,1,'CONFIRMED','CNY')"); @@ -35,6 +37,33 @@ class TripFinanceQueryServiceTest { assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); } + @Test void legalSourceAndBillStatesKeepReceivableAndRecordedReceiptAcrossLifecycle() { + for(String sourceStatus:List.of("AVAILABLE","BILLED","SETTLED"))for(String billStatus:List.of("ISSUED","PARTIALLY_PAID","PAID")) { + setup();fee(1,1,"DL-A",1,"CNY","20",sourceStatus);bill(1,1); + jdbc.update("UPDATE fin_fee_source SET status=?",sourceStatus); + jdbc.update("UPDATE fin_bill SET status=?",billStatus); + if(!"ISSUED".equals(billStatus))receipt("PAID".equals(billStatus)?"20":"3.5"); + var result=service.query(request(true)); + assertThat(result.sources()).as(sourceStatus+"/"+billStatus).hasSize(1); + assertThat(result.sources().get(0).status()).isEqualTo(sourceStatus); + assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); + assertThat(result.documents()).hasSize(1); + if("ISSUED".equals(billStatus)) { + assertThat(result.totals().get(0).receivedAmount()).isNull(); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); + } else { + assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("PAID".equals(billStatus)?"20":"3.5"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + } + } + } + @Test void lockedVoidedAndSupersededSourcesRemainExcluded() { + for(String status:List.of("LOCKED","VOIDED","SUPERSEDED")) { + setup();fee(1,1,"DL-A",1,"CNY","20",status);bill(1,1);receipt("20"); + var result=service.query(request(true));assertThat(result.sources()).as(status).isEmpty(); + assertThat(result.totals()).isEmpty();assertThat(result.documents()).isEmpty(); + } + } @Test void issuedPartialAndPaidBillsKeepTheirConfirmedAllocation() { for(String status:List.of("ISSUED","PARTIALLY_PAID","PAID")) { setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1); @@ -78,8 +107,8 @@ class TripFinanceQueryServiceTest { assertThat(service.query(request(true)).totals().get(0).receivableAmount()).isEqualByComparingTo("0"); } @Test void latestVoidedDoesNotFallBackAndOtherCustomerExcluded() { - fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");fee(2,1,"DL-A",2,"CNY","20","VOIDED"); - fee(3,2,"DL-A",1,"CNY","999","AVAILABLE"); + fee(1,1,"DL-A",1,"CNY","20","BILLED");fee(2,1,"DL-A",2,"CNY","20","VOIDED"); + fee(3,2,"DL-A",1,"CNY","999","BILLED"); var result=service.query(request(true));assertThat(result.sources()).isEmpty();assertThat(result.status()).isEqualTo("PENDING_ALLOCATION"); } @Test void currenciesSeparateAndNoReceiptPermissionMasksDocuments() { -- 2.54.0 From 090f57bfb1dde67f38855c7810904ce3451b1ec3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 16:05:06 +0800 Subject: [PATCH 170/176] fix(firstleg): stop retrying deterministic completion preflight conflicts --- .../service/LoadingCompletionWorker.java | 9 +++- .../service/LoadingCompletionWorkerTest.java | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java index 39659b1ef..c03263fc5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; @@ -63,7 +64,13 @@ public class LoadingCompletionWorker { } private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) { - return cause instanceof BizException && java.util.Set.of( + if (!(cause instanceof BizException business)) { + return false; + } + if ("COMPLETED_EVENT_READY".equals(stage)) { + return business.getResultCode() == ResultCode.CONFLICT; + } + return java.util.Set.of( "OWNER_DISCOVERY", "OWNER_READY", "PLAN_GATE", "TASK_REVIEW", "PLAN_COMPLETE", "COMPLETED_EVENT").contains(stage); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java index 519c17fc1..264f0d2c2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java @@ -81,6 +81,57 @@ class LoadingCompletionWorkerTest { verify(picks).failCompletion(20L); } + @Test + void completedEventPreflightBusinessFailureIsTerminalOnFirstAttempt() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("COMPLETED_EVENT_READY", + new BizException(ResultCode.CONFLICT, "FBA warehouse internal code is missing"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("COMPLETED_EVENT_READY"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("COMPLETED_EVENT_READY"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null)); + verify(picks).failCompletion(20L); + } + + @Test + void completedEventPreflightUnexpectedFailureRemainsRetryable() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("COMPLETED_EVENT_READY", + new IllegalStateException("temporary database failure"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class), + eq("COMPLETED_EVENT_READY"), eq("S0500"), eq(null), eq(null)); + verify(picks, org.mockito.Mockito.never()).failCompletion(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void completedEventPreflightWarehouseUnavailableBusinessFailureRemainsRetryable() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + // LoadingPalletLabelService wraps warehouse Feign/circuit-breaker failures in this BizException. + doThrow(new LoadingPickService.CompletionProcessingException("COMPLETED_EVENT_READY", + new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse master data temporarily unavailable"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class), + eq("COMPLETED_EVENT_READY"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)); + verify(picks, org.mockito.Mockito.never()).failCompletion(org.mockito.ArgumentMatchers.anyLong()); + } + private static LoadingCompletionCommandEntity command(int attemptCount) { LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity(); command.setId(900L); -- 2.54.0 From 261c720c77135320d5c77c8565d4275f4ce2aa4d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 16:35:22 +0800 Subject: [PATCH 171/176] fix(firstleg): read concurrent customs initialization with current row lock --- .../service/ContainerCustomsService.java | 12 +++- .../ContainerCustomsMapperContractTest.java | 10 ++++ .../ContainerCustomsServiceTest.java | 59 ++++++++++++++++++- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index f05f4adeb..faa94dbaf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -563,7 +563,12 @@ public class ContainerCustomsService { if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY); - return refresh(planId); + // A concurrent INSERT IGNORE may wait for another request's insert to commit. The + // transaction's earlier plan reads can retain an RR snapshot that cannot see that row. + // Read the winning aggregate under its row lock, rather than reusing a snapshot read. + ContainerRow container = mapper.lockContainer(planId); + if (container == null) throw conflict("整柜报关记录初始化失败"); + return refresh(planId, container); } private ContainerRow ensureMutable(long planId) { @@ -573,7 +578,10 @@ public class ContainerCustomsService { } private ContainerRow refresh(long planId) { - ContainerRow value = mapper.container(planId); + return refresh(planId, mapper.container(planId)); + } + + private ContainerRow refresh(long planId, ContainerRow value) { if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value; String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING; if (!status.equals(value.status())) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index 1e1a59fab..a8f005c3b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -11,6 +11,16 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class ContainerCustomsMapperContractTest { + @Test void initializationFlushesSessionCacheBeforeCurrentAggregateRead() { + var configuration = new org.apache.ibatis.session.Configuration(); + configuration.addMapper(ContainerCustomsMapper.class); + String namespace = ContainerCustomsMapper.class.getName() + "."; + assertThat(configuration.getMappedStatement(namespace + "insertContainer").isFlushCacheRequired()).isTrue(); + var currentRead = configuration.getMappedStatement(namespace + "lockContainer"); + assertThat(currentRead.getBoundSql(java.util.Map.of("planId",9L)).getSql()).contains("FOR UPDATE"); + assertThat(currentRead.getCache()).isNull(); + } + @Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception { String value=sql("completedPlanForOrder",long.class,long.class); assertThat(value) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 153637d77..d017e50fa 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -52,6 +52,7 @@ class ContainerCustomsServiceTest { when(mapper.planInbounds(9L)).thenReturn(List.of()); when(mapper.plan(9L)).thenReturn(plan); when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0)); + when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.READY,0)); var view=service.platformView(9L); @@ -78,11 +79,12 @@ class ContainerCustomsServiceTest { when(mapper.planInbounds(9L)).thenReturn(List.of()); when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C")); when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0)); + when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.READY,0)); assertThatThrownBy(()->service.start(9L,5L,"admin","empty-plan",new ActionRequest(0,true,null))) .isInstanceOf(BizException.class).hasMessageContaining("没有实际货物,无需报关"); - verify(mapper,never()).lockContainer(9L); + verify(mapper).lockContainer(9L); verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString()); } @@ -91,6 +93,7 @@ class ContainerCustomsServiceTest { when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L); when(mapper.planInbounds(9L)).thenReturn(List.of(row)); when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,2)); + when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.READY,2)); when(mapper.actualBoxCount(9L,7L,"TAX_REFUND")).thenReturn(3L); var view=service.merchantOrderView(7L,21L); @@ -101,6 +104,56 @@ class ContainerCustomsServiceTest { assertThat(view.inbounds()).extracting(value->value.orderId()).containsExactly(21L); } + @ParameterizedTest + @CsvSource({"WAITING_CUSTOMER_DOCUMENTS", "DECLARING", "COMPLETED"}) + void concurrentFirstReadUsesCommittedWinnerInsteadOfOldSnapshot(String status) throws Exception { + var own = new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var other = new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",12L,22L,"OTHER","OTHER-I",8L,"其他客户","TAX_REFUND"); + when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L); + when(mapper.planInbounds(9L)).thenReturn(List.of(own, other)); + // RR snapshot predates another request's committed insert; INSERT IGNORE lost that race. + when(mapper.insertContainer(anyLong(),eq(9L),eq(8L),anyString())).thenReturn(0); + when(mapper.container(9L)).thenReturn(null); + when(mapper.lockContainer(9L)).thenReturn(container(status, 3)); + when(mapper.missingRefundCount(9L,2L)).thenReturn(2); + when(mapper.actualBoxCount(9L,7L,"TAX_REFUND")).thenReturn(100L); + + var pool = Executors.newFixedThreadPool(4); + try { + var reads = java.util.stream.IntStream.range(0,4) + .mapToObj(index -> pool.submit(() -> service.merchantOrderView(7L,21L))).toList(); + for (var read : reads) { + var view = read.get(5,TimeUnit.SECONDS); + assertThat(view.status()).isEqualTo(status); + assertThat(view.version()).isEqualTo(3); + assertThat(view.actualBoxCount()).isEqualTo(100); + assertThat(view.inbounds()).extracting(value->value.orderId()).containsExactly(21L); + } + } finally { + pool.shutdownNow(); + } + verify(mapper,times(4)).lockContainer(9L); + verify(mapper,never()).container(9L); + verify(mapper,never()).refreshStatus(anyLong(),anyInt(),anyString()); + } + + @Test void currentAggregateStillRefreshesCoverageStatusAfterInitialization() { + var row = new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L); + when(mapper.planInbounds(9L)).thenReturn(List.of(row)); + when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.WAITING,3)); + when(mapper.missingRefundCount(9L,2L)).thenReturn(0); + when(mapper.refreshStatus(2L,3,ContainerCustomsService.READY)).thenReturn(1); + // This transaction's own update is visible to its subsequent read. + when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,4)); + + var view = service.merchantOrderView(7L,21L); + + assertThat(view.status()).isEqualTo(ContainerCustomsService.READY); + assertThat(view.version()).isEqualTo(4); + verify(mapper).refreshStatus(2L,3,ContainerCustomsService.READY); + } + @Test void uploadedSourceViewReturnsPersistedNonNullVersion(){ var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); var aggregate=container(ContainerCustomsService.READY,2); @@ -174,6 +227,7 @@ class ContainerCustomsServiceTest { when(mapper.planInbounds(9L)).thenReturn(List.of(inbound)); when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C")); when(mapper.container(9L)).thenReturn(declaring); + when(mapper.lockContainer(9L)).thenReturn(declaring); when(mapper.actualBoxCount(9L,null,null)).thenReturn(0L); when(mapper.finalDocument(31L)).thenReturn(null); when(mapper.completion(31L)).thenReturn(null); @@ -215,6 +269,7 @@ class ContainerCustomsServiceTest { var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1}); when(mapper.planInbounds(9L)).thenReturn(List.of(inbound)); when(mapper.container(9L)).thenReturn(declaring); + when(mapper.lockContainer(9L)).thenReturn(declaring); when(mapper.document(1L)).thenReturn(source); when(mapper.lockContainerById(2L)).thenReturn(declaring); @@ -425,7 +480,7 @@ class ContainerCustomsServiceTest { var completed=container(ContainerCustomsService.COMPLETED,4); var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION"); when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1); - when(mapper.lockContainer(9L)).thenReturn(declaring); + when(mapper.lockContainer(9L)).thenReturn(declaring,completed); when(mapper.planInbounds(9L)).thenReturn(List.of(buy)); when(mapper.complete(2L,3,5L,"admin")).thenReturn(1); when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1); -- 2.54.0 From 4bde1a8352bbd5c069857299f082cf57d8c344c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 17:03:01 +0800 Subject: [PATCH 172/176] fix(firstleg): avoid shared lock upgrades when initializing customs --- .../customs/container/mapper/ContainerCustomsMapper.java | 3 ++- .../customs/container/service/ContainerCustomsService.java | 7 ++++--- .../container/ContainerCustomsMapperContractTest.java | 7 +++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 96b2c564d..0cb37d577 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -88,9 +88,10 @@ public interface ContainerCustomsMapper { Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); @Insert(""" - INSERT IGNORE INTO fl_customs_container + INSERT INTO fl_customs_container (id,loading_plan_id,container_booking_id,status,created_at,updated_at,version) VALUES (#{id},#{planId},#{containerBookingId},#{status},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0) + ON DUPLICATE KEY UPDATE id=id """) int insertContainer(@Param("id") long id,@Param("planId") long planId, @Param("containerBookingId") long containerBookingId,@Param("status") String status); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index faa94dbaf..7882613ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -563,9 +563,10 @@ public class ContainerCustomsService { if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY); - // A concurrent INSERT IGNORE may wait for another request's insert to commit. The - // transaction's earlier plan reads can retain an RR snapshot that cannot see that row. - // Read the winning aggregate under its row lock, rather than reusing a snapshot read. + // The no-op upsert already takes an exclusive lock on a duplicate, avoiding the + // shared-to-exclusive lock upgrade of INSERT IGNORE followed by FOR UPDATE. + // Earlier plan reads may retain an RR snapshot predating the winning insert, so + // retrieve the real aggregate with a current read, not a snapshot read. ContainerRow container = mapper.lockContainer(planId); if (container == null) throw conflict("整柜报关记录初始化失败"); return refresh(planId, container); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index a8f005c3b..101bcff6c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -11,6 +11,13 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class ContainerCustomsMapperContractTest { + @Test void duplicateInitializationTakesUpdateLockWithoutChangingExistingFacts() throws Exception { + String sql = String.join(" ", ContainerCustomsMapper.class.getMethod("insertContainer", + long.class,long.class,long.class,String.class).getAnnotation(org.apache.ibatis.annotations.Insert.class).value()); + assertThat(sql).contains("ON DUPLICATE KEY UPDATE id=id").doesNotContain("INSERT IGNORE"); + assertThat(sql.substring(sql.indexOf("ON DUPLICATE KEY UPDATE"))).isEqualTo("ON DUPLICATE KEY UPDATE id=id\n"); + } + @Test void initializationFlushesSessionCacheBeforeCurrentAggregateRead() { var configuration = new org.apache.ibatis.session.Configuration(); configuration.addMapper(ContainerCustomsMapper.class); -- 2.54.0 From 60543adf6326f6613ab3a6bf50c610750c30791e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 6 Oct 2026 00:20:43 +0800 Subject: [PATCH 173/176] fix(lastleg): prevent transfer business number collisions --- .../lastleg/service/LastlegOrderNoGenerator.java | 5 +++++ .../lastleg/service/LastlegOrderNoGeneratorTest.java | 10 ++++++++++ .../transfer/service/WarehouseTransferServiceTest.java | 6 +++--- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index fb34ba751..9c6f8cb0f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -47,6 +47,11 @@ public class LastlegOrderNoGenerator { // Return numbers are independent business identifiers, not truncated database IDs. // Keep every UUID bit; the longest result (RETU + 25 base-36 digits) fits VARCHAR(32). if (returnPrefix != null) return returnPrefix + "-" + encodeReturnUuid(UUID.randomUUID()); + // Transfer numbers also identify synthetic inbound events globally. Truncating + // a database ID to six digits can collide between unrelated fresh transfers. + if ("TRANSFER".equals(code) || "WAREHOUSE_TRANSFER".equals(code)) { + return "WT-" + encodeReturnUuid(UUID.randomUUID()); + } String type = normalizeType(businessType); return type + "-" + warehouseCode(warehouse) + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 94b6c5316..8bef1431f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -81,4 +81,14 @@ class LastlegOrderNoGeneratorTest { assertTrue(value.matches("OB-LAX-\\d{8}-000123"), value); assertTrue(value.length() <= 24); } + + @Test void transferNumbersDoNotCollideWhenDatabaseIdSuffixesRepeat() { + String first = generator.next("TRANSFER", "warehouse-LAX-01", 123L); + String second = generator.next("TRANSFER", "warehouse-LAX-01", 1_000_123L); + String repeatedId = generator.next("WAREHOUSE_TRANSFER", "warehouse-LAX-01", 123L); + assertTrue(first.matches("WT-[0-9A-Z]{25}"), first); + assertTrue(first.length() <= 32); + assertNotEquals(first, second); + assertNotEquals(first, repeatedId); + } } 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 a0015ff55..53b05d619 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 @@ -231,7 +231,7 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "relabelService", relabelService); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().when(orderNoGenerator.next(anyString(), any(), anyLong())) - .thenReturn("WT-WH-20260901-000001"); + .thenReturn("WT-0000000000000000000000001"); lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); @@ -386,7 +386,7 @@ class WarehouseTransferServiceTest { assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType()); assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus()); assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); - assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}")); + assertTrue(tailOrder.getValue().getOrderNo().matches("WT-[0-9A-Z]{25}")); assertNull(tailOrder.getValue().getTransferWarehouseId()); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); verify(relabelApplications, never()).insert(any(RelabelApplication.class)); @@ -544,7 +544,7 @@ class WarehouseTransferServiceTest { assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() .map(WarehouseTransferCarton::getCartonIndex).toList()); assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark() - .matches("WT-WH-\\d{8}-\\d{6}#5-[1-5]"))); + .matches("WT-[0-9A-Z]{25}#5-[1-5]"))); } @Test -- 2.54.0 From bc8c1467ee3d777cb12ede42fe915cda97c95a5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 6 Oct 2026 00:52:48 +0800 Subject: [PATCH 174/176] fix(lastleg): return persisted grouped dispatch replay facts --- .../dispatch/service/DispatchService.java | 7 +++- .../dispatch/service/DispatchServiceTest.java | 32 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 63840c822..62a86b738 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -556,7 +556,12 @@ public class DispatchService { drafts.updateById(draft); } } - return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + // Exact grouped replay must return the same persisted timestamp precision + // on the initial call as on later reads from DATETIME(3). + DispatchOrder responseOrder = preserveExactReplay + ? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing") + : dispatch; + return response(responseOrder, items.findByDispatchOrder(customerId, dispatch.getId())); } @Transactional diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 2bcef6e42..1cbb76918 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -584,6 +584,14 @@ class DispatchServiceTest { verify(orders, never()).insert(any(DispatchOrder.class)); } + @Test + void groupedInitialResponseUsesPersistedTimestampPrecisionForExactReplay() { + var fixture = twoGroupFixture(true); + var first = service.preArrangeDraft("7", fixture.request()); + assertEquals(123_000_000, first.get(0).dispatch().createdAt().getNano()); + assertEquals(first, service.preArrangeDraft("7", fixture.request())); + } + @Test void changedMembershipDuplicateSourceExpiredDraftAndCapacityFailBeforeCreatingAnyGroup() { var fixture = twoGroupFixture(); @@ -613,7 +621,8 @@ class DispatchServiceTest { var jdbc = new org.springframework.jdbc.core.JdbcTemplate(ds); jdbc.execute("CREATE TABLE dispatch_probe(id BIGINT PRIMARY KEY)"); org.mockito.Mockito.doAnswer(call -> { var row=(DispatchOrder)call.getArgument(0); - jdbc.update("INSERT INTO dispatch_probe VALUES(?)",row.getId());return 1; }).when(orders).insert(any(DispatchOrder.class)); + jdbc.update("INSERT INTO dispatch_probe VALUES(?)",row.getId()); + fixture.savedOrders().put(row.getId(), row);return 1; }).when(orders).insert(any(DispatchOrder.class)); var transaction = new org.springframework.transaction.support.TransactionTemplate( new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds)); assertThrows(ResponseStatusException.class, () -> transaction.execute(status -> service.preArrangeDraft("7",fixture.request()))); @@ -622,6 +631,10 @@ class DispatchServiceTest { } private GroupFixture twoGroupFixture() { + return twoGroupFixture(false); + } + + private GroupFixture twoGroupFixture(boolean databaseTimestampPrecision) { var draft = new DispatchDraft(); draft.setId(1L);draft.setDraftToken("draft-multi");draft.setOperatorId("7"); draft.setStatus("ACTIVE");draft.setExpiresAt(LocalDateTime.now().plusMinutes(30)); lenient().when(drafts.lockActive("draft-multi","7")).thenReturn(draft); @@ -629,7 +642,17 @@ class DispatchServiceTest { var allCandidates = new java.util.ArrayList(); var savedOrders = new java.util.HashMap(); var savedItems = new java.util.HashMap>(); - lenient().doAnswer(call -> {var row=(DispatchOrder)call.getArgument(0);savedOrders.put(row.getId(),row);return 1;}) + lenient().doAnswer(call -> { + var row=(DispatchOrder)call.getArgument(0); + if (databaseTimestampPrecision) { + row.setCreatedAt(row.getCreatedAt().withNano(123_456_000)); + var persisted = new DispatchOrder(); + org.springframework.beans.BeanUtils.copyProperties(row, persisted); + persisted.setCreatedAt(row.getCreatedAt().withNano(123_000_000)); + savedOrders.put(row.getId(), persisted); + } else savedOrders.put(row.getId(),row); + return 1; + }) .when(orders).insert(any(DispatchOrder.class)); lenient().when(orders.lockById(eq("100"),any())).thenAnswer(call -> savedOrders.get(call.getArgument(1))); lenient().doAnswer(call -> {var row=(DispatchItem)call.getArgument(0);savedItems.computeIfAbsent(row.getDispatchOrderId(),k->new java.util.ArrayList<>()).add(row);return 1;}) @@ -651,11 +674,12 @@ class DispatchServiceTest { } lenient().when(draftContainers.findByToken("draft-multi")).thenReturn(containerList); return new GroupFixture(draft,containerList,allCandidates,new DispatchRequests.PreArrangeDraft("draft-multi",List.of( - new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()),new DispatchRequests.DraftGroup(22L,List.of(12L,13L),List.of())))); + new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()),new DispatchRequests.DraftGroup(22L,List.of(12L,13L),List.of()))),savedOrders); } private record GroupFixture(DispatchDraft draft,List containers, - List candidates,DispatchRequests.PreArrangeDraft request) { } + List candidates,DispatchRequests.PreArrangeDraft request, + java.util.Map savedOrders) { } private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { return candidate(planPalletId, warehousePalletId, destination, 12); -- 2.54.0 From 6346e7bd95a5b54eec9258300e129d00baf611c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 6 Oct 2026 10:07:23 +0800 Subject: [PATCH 175/176] fix(lastleg): use reassigned FBA destination in inbound plans --- .../mapper/InboundPlanOrderMapper.java | 5 +- .../InboundPlanOrderMapperContractTest.java | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index c21a77c65..6d43cc29d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -38,7 +38,8 @@ public interface InboundPlanOrderMapper { "o.inbound_order_no AS inboundOrderNo,o.container_no AS containerNo,COALESCE(o.pallet_count,0) AS recommendedPalletCount," + "COALESCE(o.package_count,0) AS packageCount,COALESCE(o.volume_cbm,0) AS volumeCbm," + "o.overseas_warehouse_id AS overseasWarehouseId,o.overseas_warehouse_code AS overseasWarehouseCode," + - "o.overseas_warehouse_name AS overseasWarehouseName,o.fba_warehouse_code AS fbaWarehouseCode," + + "o.overseas_warehouse_name AS overseasWarehouseName," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),(SELECT MIN(NULLIF(d.destination_warehouse_id,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," + @@ -92,7 +93,7 @@ public interface InboundPlanOrderMapper { "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + "COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," + "o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," + - "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java index e9b5e1e48..1d3bf75e9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java @@ -2,10 +2,64 @@ package com.baiyue.lastleg.inboundplan.mapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class InboundPlanOrderMapperContractTest { + @Test + void reassignedDestinationUsesOneFbaCodeForPlanAndDispatch() throws Exception { + var jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute(""" + CREATE TABLE ll_order (id BIGINT, customer_id VARCHAR(32), container_no VARCHAR(32), + inbound_code VARCHAR(32), current_status VARCHAR(32), inbound_order_no VARCHAR(32), + volume_cbm DECIMAL(12,3), package_count INT, pallet_count INT, + overseas_warehouse_id VARCHAR(32), overseas_warehouse_code VARCHAR(32), + overseas_warehouse_name VARCHAR(64), fba_warehouse_code VARCHAR(32), + transfer_warehouse_id VARCHAR(32), transfer_warehouse_code VARCHAR(32), + transfer_warehouse_name VARCHAR(64), detained BOOLEAN, version BIGINT) + """); + jdbc.execute(""" + CREATE TABLE ll_order_detail (order_id BIGINT, destination_warehouse_id VARCHAR(32), + destination_warehouse_code VARCHAR(32), destination_warehouse_name VARCHAR(64)) + """); + jdbc.update(""" + INSERT INTO ll_order (id,customer_id,fba_warehouse_code,transfer_warehouse_id, + transfer_warehouse_code,transfer_warehouse_name) VALUES (1,'C1','ABE3','22','ABE2','New FBA') + """); + jdbc.update("INSERT INTO ll_order_detail VALUES (1,'22','ABE2','New FBA')"); + String sql = String.join(" ", InboundPlanOrderMapper.class + .getMethod("lockOrder", String.class, Long.class).getAnnotation(Select.class).value()) + .replace("#{customerId}", "?").replace("#{orderId}", "?"); + // H2 cannot lock grouped queries; exercise the production projection and scope here. + sql = sql.replace(" FOR UPDATE", ""); + var row = jdbc.queryForMap(sql, "C1", 1L); + assertEquals("ABE2", row.get("fbaWarehouseCode")); + assertEquals(row.get("destinationWarehouseCode"), row.get("fbaWarehouseCode")); + assertEquals("22", row.get("destinationWarehouseId")); + assertTrue(jdbc.queryForList(sql, "OTHER", 1L).isEmpty()); + jdbc.update("UPDATE ll_order SET transfer_warehouse_id=NULL,transfer_warehouse_code='',transfer_warehouse_name=NULL WHERE id=1"); + jdbc.update("UPDATE ll_order_detail SET destination_warehouse_id='33',destination_warehouse_code='ABE3' WHERE order_id=1"); + row = jdbc.queryForMap(sql, "C1", 1L); + assertEquals("ABE3", row.get("fbaWarehouseCode")); + assertEquals(row.get("destinationWarehouseCode"), row.get("fbaWarehouseCode")); + } + + @Test + void containerPreviewAndLockedSnapshotUseTheSameEffectiveFbaDestination() throws Exception { + for (var method : InboundPlanOrderMapper.class.getMethods()) { + if (!method.getName().equals("lockOrder") && !method.getName().equals("containerPreviewOrders")) continue; + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode"), + method.getName()); + } + } + @Test void transferWarehouseIsTheEffectivePalletDestination() throws Exception { Select select = InboundPlanOrderMapper.class -- 2.54.0 From ec0c06ff88e6eda987c42b0dff2ddbd2d8ed0921 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 11:02:47 +0800 Subject: [PATCH 176/176] fix(deploy): publish unified services to temporary gray environment --- Jenkinsfile | 6 +- Jenkinsfile.service | 40 ++++++++++- deploy/docker-compose.yml | 41 +++++------ deploy/scripts/deploy-built-service.sh | 47 +++++++++++-- scripts/audit-deployment-contract.test.mjs | 2 +- scripts/controller-pipeline-contract.test.mjs | 49 +++++-------- .../deploy-built-service-rollback.test.mjs | 70 +++++++++++++++++-- scripts/overseas-deployment-contract.test.mjs | 8 +-- ...est-environment-pipeline-contract.test.mjs | 20 ++++-- 9 files changed, 211 insertions(+), 72 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 3659d5dfb..36bbea19e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -111,6 +111,8 @@ def fixedEnvironmentForJob(String jobBaseName) { switch (jobBaseName) { case 'backend-java': return 'test' + case 'backend-java-prod': + return 'production' default: return null } @@ -120,6 +122,8 @@ def fixedDownstreamJobPrefixForJob(String jobBaseName) { switch (jobBaseName) { case 'backend-java': return 'baiyue-firstleg-' + case 'backend-java-prod': + return 'baiyue-firstleg-prod-' default: return null } @@ -174,7 +178,7 @@ pipeline { } if (params.ENVIRONMENT != resolvedEnvironment || params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { - error('当前流水线仅允许 TEST 与 baiyue-firstleg- 下游前缀') + error('部署环境和下游前缀必须与当前 Job 固定映射一致') } env.RESOLVED_ENVIRONMENT = resolvedEnvironment echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" diff --git a/Jenkinsfile.service b/Jenkinsfile.service index a11c90f78..310979127 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -20,6 +20,7 @@ def serviceJars = [ ] def fixedEnvironmentForServiceJob(String jobBaseName) { + if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) return 'production' if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) { return 'test' } @@ -89,7 +90,7 @@ pipeline { error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") } if (params.ENVIRONMENT != resolvedEnvironment) { - error('当前单服务流水线仅允许 TEST') + error('部署环境必须与当前单服务 Job 固定映射一致') } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') @@ -120,6 +121,13 @@ pipeline { git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" + if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || { + echo "灰度发布提交必须已经合并到 main" >&2 + exit 1 + } + fi ''' @@ -188,7 +196,7 @@ pipeline { } stage('部署 TEST') { - when { expression { params.DEPLOY } } + when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'test' } } agent none steps { script { @@ -209,6 +217,34 @@ pipeline { } } } + + stage('部署临时灰度') { + when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } } + agent none + steps { + script { + lock(resource: "firstleg-production-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) { + node('java-builder') { + deleteDir() + unstash 'deployment-files' + withCredentials([ + file(credentialsId: 'firstleg-production-env', variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + withEnv(['TARGET_IS_LOCAL=false', 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, + "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) { + sh 'sh deploy/scripts/deploy-built-service.sh production "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"' + } + } + } + } + } + } + } + } post { diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 472f1e6a3..56f4febb3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -name: firstleg-test +name: firstleg-${DEPLOY_ENV:-test} x-common: &common restart: "on-failure:3" @@ -8,17 +8,17 @@ x-common: &common extra_hosts: - "host.docker.internal:host-gateway" environment: &common-environment - DEPLOY_ENV: test + DEPLOY_ENV: ${DEPLOY_ENV:-test} BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} SERVER_SHUTDOWN: graceful SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: test + SPRING_PROFILES_ACTIVE: ${DEPLOY_ENV:-test} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_USERNAME: ${NACOS_USERNAME} NACOS_PASSWORD: ${NACOS_PASSWORD} NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual TEST namespace ID required} + NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual environment namespace ID required} REDIS_HOST: ${REDIS_HOST} REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PASSWORD: ${REDIS_PASSWORD} @@ -32,7 +32,7 @@ x-common: &common REDIS_TOKEN_KEY_PREFIX: ${REDIS_TOKEN_KEY_PREFIX:-baiyue:auth} SPRING_SQL_INIT_MODE: never ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER} - MQ_TOPIC_PREFIX: test- + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} BAIYUE_AUDIT_ENDPOINT: http://audit-service:9700/internal/audit/events SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} @@ -50,11 +50,12 @@ services: gateway: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} - container_name: firstleg-test-gateway + container_name: firstleg-${DEPLOY_ENV:-test}-gateway ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] environment: <<: *common-environment SERVER_PORT: 9000 + SPRING_PROFILES_ACTIVE: ${DEPLOY_ENV:-test},cn SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} @@ -62,7 +63,7 @@ services: auth: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} - container_name: firstleg-test-auth + container_name: firstleg-${DEPLOY_ENV:-test}-auth expose: ["9100"] environment: <<: *common-environment @@ -76,7 +77,7 @@ services: iam: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} - container_name: firstleg-test-iam + container_name: firstleg-${DEPLOY_ENV:-test}-iam expose: ["9201"] environment: <<: *common-environment @@ -90,7 +91,7 @@ services: firstleg: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} - container_name: firstleg-test-business + container_name: firstleg-${DEPLOY_ENV:-test}-business expose: ["9202"] environment: <<: *common-environment @@ -133,7 +134,7 @@ services: finance: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} - container_name: firstleg-test-finance + container_name: firstleg-${DEPLOY_ENV:-test}-finance expose: ["9203"] environment: <<: *common-environment @@ -156,7 +157,7 @@ services: order: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} - container_name: firstleg-test-order + container_name: firstleg-${DEPLOY_ENV:-test}-order expose: ["9204"] environment: <<: *common-environment @@ -168,7 +169,7 @@ services: warehouse: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} - container_name: firstleg-test-warehouse + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse expose: ["9205"] environment: <<: *common-environment @@ -180,7 +181,7 @@ services: file: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} - container_name: firstleg-test-file + container_name: firstleg-${DEPLOY_ENV:-test}-file expose: ["9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" @@ -202,7 +203,7 @@ services: print: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} - container_name: firstleg-test-print + container_name: firstleg-${DEPLOY_ENV:-test}-print expose: ["9207"] environment: <<: *common-environment @@ -212,7 +213,7 @@ services: integration-service: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} - container_name: firstleg-test-integration + container_name: firstleg-${DEPLOY_ENV:-test}-integration expose: ["9210"] environment: <<: *common-environment @@ -261,7 +262,7 @@ services: audit-service: <<: *common image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} - container_name: firstleg-test-audit + container_name: firstleg-${DEPLOY_ENV:-test}-audit expose: ["9700"] environment: <<: *common-environment @@ -276,7 +277,7 @@ services: lastleg-service: <<: *common image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} - container_name: firstleg-test-lastleg + container_name: firstleg-${DEPLOY_ENV:-test}-lastleg expose: ["9303"] environment: <<: *common-environment @@ -303,7 +304,7 @@ services: warehouse-overseas-service: <<: *common image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} - container_name: firstleg-test-warehouse-overseas + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas expose: ["9305"] environment: <<: *common-environment @@ -316,7 +317,7 @@ services: networks: firstleg-network: - name: firstleg-test-network + name: firstleg-${DEPLOY_ENV:-test}-network infra-network: external: true - name: ${INFRA_NETWORK:-firstleg-test-infra-network} + name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network} diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 3846f5b8d..995da8f58 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -29,18 +29,23 @@ for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do done -[ "$DEPLOY_ENV" = "test" ] || { echo "Only test is supported" >&2; exit 2; } +case "$DEPLOY_ENV" in test|production) ;; *) echo "Only test and production are supported" >&2; exit 2 ;; esac case "$SERVICE" in gateway|auth|iam|firstleg|finance|order|warehouse|file|print|integration-service|lastleg-service|warehouse-overseas-service|audit-service) ;; *) echo "Unknown or retired service: $SERVICE" >&2; exit 2 ;; esac -[ "${TARGET_IS_LOCAL:-false}" = "true" ] || { echo "TEST deploy must run on the target host" >&2; exit 2; } +case "$IMAGE_REPOSITORY" in ''|*[!a-zA-Z0-9._:/-]*) echo "Invalid image repository" >&2; exit 2 ;; esac +case "$IMAGE_TAG" in ''|*[!a-zA-Z0-9_.-]*) echo "Invalid image tag" >&2; exit 2 ;; esac +case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esac +if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then + echo "TEST deploy must run on the target host" >&2; exit 2 +fi [ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done -[ "$(env_file_value MQ_TOPIC_PREFIX)" = "test-" ] || { echo "MQ_TOPIC_PREFIX must be test-" >&2; exit 2; } +[ "$(env_file_value MQ_TOPIC_PREFIX)" = "$DEPLOY_ENV-" ] || { echo "MQ_TOPIC_PREFIX must be $DEPLOY_ENV-" >&2; exit 2; } case "$SERVICE" in gateway|print|integration-service) db_prefix='' ;; lastleg-service) db_prefix=LASTLEG ;; @@ -138,6 +143,7 @@ wait_for_local_health() { } deploy_local() { + export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG deploy_docker_config=$(mktemp -d) DOCKER_CONFIG="$deploy_docker_config" export DOCKER_CONFIG @@ -223,4 +229,37 @@ deploy_local() { return 1 } -deploy_local +deploy_remote() { + : "${TARGET_SSH_TARGET:?TARGET_SSH_TARGET required}" + : "${TARGET_SSH_KEY:?TARGET_SSH_KEY required}" + : "${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS required}" + [ -f "$TARGET_SSH_KEY" ] && [ -f "$TARGET_SSH_KNOWN_HOSTS" ] || exit 2 + run_ssh() { + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$TARGET_SSH_TARGET" "$@" + } + run_scp() { + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" + } + remote_dir=$(run_ssh 'umask 077; mktemp -d /tmp/firstleg-production-release.XXXXXXXXXX') + case "$remote_dir" in /tmp/firstleg-production-release.*) ;; *) echo "Invalid remote temporary directory" >&2; exit 2 ;; esac + case "$remote_dir" in *[!a-zA-Z0-9/._-]*) exit 2 ;; esac + cleanup_remote() { run_ssh "rm -rf -- '$remote_dir'" >/dev/null 2>&1; } + trap cleanup_remote EXIT HUP INT TERM + run_ssh "mkdir -p '$remote_dir/deploy/scripts'" + run_scp "$ENV_FILE" "$TARGET_SSH_TARGET:$remote_dir/deployment.env" + run_scp "$COMPOSE_FILE" "$TARGET_SSH_TARGET:$remote_dir/deploy/docker-compose.yml" + run_scp deploy/scripts/deploy-built-service.sh "$TARGET_SSH_TARGET:$remote_dir/deploy/scripts/deploy-built-service.sh" + # Registry credentials only travel over stdin, never in process arguments or files. + { printf '%s\n' "$REGISTRY_USER"; printf '%s' "$REGISTRY_PASSWORD"; } | run_ssh \ + "cd '$remote_dir' && IFS= read -r REGISTRY_USER && REGISTRY_PASSWORD=\$(cat) && export REGISTRY_USER REGISTRY_PASSWORD TARGET_IS_LOCAL=true ALLOW_SAME_CONTRACT_ROLLBACK='${ALLOW_SAME_CONTRACT_ROLLBACK:-false}' DEPLOY_HEALTH_TIMEOUT_SECONDS='$HEALTH_TIMEOUT_SECONDS' DEPLOY_HEALTH_POLL_SECONDS='$HEALTH_POLL_SECONDS' && sh deploy/scripts/deploy-built-service.sh production '$IMAGE_REPOSITORY' '$IMAGE_TAG' '$SERVICE' deployment.env" + cleanup_remote + trap - EXIT HUP INT TERM +} + +if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then + deploy_local +else + deploy_remote +fi diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs index 03b984051..3424924e8 100644 --- a/scripts/audit-deployment-contract.test.mjs +++ b/scripts/audit-deployment-contract.test.mjs @@ -12,7 +12,7 @@ test('基础 Compose 显式配置审计服务地址,避免静默退化为日 /BAIYUE_AUDIT_ENDPOINT: http:\/\/audit-service:9700\/internal\/audit\/events/) assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m) assert.match(compose, /^ audit-service:\s*$/m) - assert.match(compose, /container_name: firstleg-test-audit/) + assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/) assert.match(compose, /networks: \[firstleg-network, infra-network\]/) }) diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 7680e9048..56f806fe3 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -52,44 +52,33 @@ test('coordinator cannot initialize test databases', () => { assert.doesNotMatch(pipeline, /测试数据库初始化/) }) -test('coordinator defaults event bridge deployments to both regions', () => { - assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/) - assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/) - assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/) - assert.doesNotMatch(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) - assert.match(pipeline, /String downstreamImageTag = deployBothBridges[\s\S]*?\? "\$\{env\.IMAGE_TAG\}-both"/) - assert.match(pipeline, /String downstreamBridgeRegion = deployBothBridges[\s\S]*?\? 'CN'/) - assert.match(pipeline, /service == 'event-bridge-service' \? params\.EVENT_BRIDGE_REGION : 'CN'/) - assert.match(pipeline, /string\(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion\)/) +test('coordinator dispatches exactly the current TEST service map without retired regional units', async () => { + const serviceMap = await readFile(new URL('../deploy/service-map.yml', import.meta.url), 'utf8') + const expected = [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map(match => match[1]).sort() + const declaration = pipeline.match(/def serviceModules = \[([\s\S]*?)\]/)?.[1] || '' + const actual = [...declaration.matchAll(/'([^']+)'/g)].map(match => match[1]).sort() + assert.equal(expected.length, 13) + assert.deepEqual(actual, expected) + assert.doesNotMatch(pipeline, /EVENT_BRIDGE_REGION|gateway-overseas|event-bridge-service/) }) -test('production deploy uses DEPLOY as the single explicit release switch', () => { +test('TEST and gray jobs use fixed independent environment mappings and explicit DEPLOY', () => { assert.match(pipeline, /booleanParam\(name: 'DEPLOY'/) - assert.match(pipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.match(pipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.match(pipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) + assert.match(pipeline, /case 'backend-java-prod':[\s\S]*?return 'baiyue-firstleg-prod-'/) + assert.doesNotMatch(pipeline, /return 'development'|backend-java-dev/) + assert.match(pipeline, /booleanParam\(name: 'DEPLOY', value: params\.DEPLOY\)/) assert.doesNotMatch(pipeline, /input message:/) assert.doesNotMatch(pipeline, /\u786e\u8ba4\u90e8\u7f72/) }) -test('production deploy reports one aggregated result to WeCom', () => { - const sourceCommitIndex = pipeline.indexOf("env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD'") - const productionGuardIndex = pipeline.indexOf("if (env.RESOLVED_ENVIRONMENT == 'production')") - - assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/) - assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/) - assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/) - assert.ok(sourceCommitIndex >= 0, 'source commit must be recorded for notifications') - assert.ok(sourceCommitIndex < productionGuardIndex, - 'source commit must be recorded before production validation can fail') +test('TEST coordinator records downstream identity and fails the aggregate on any unsuccessful service', () => { + assert.match(pipeline, /env\.SOURCE_COMMIT = sh\(script: 'git rev-parse HEAD'/) assert.match(pipeline, /def downstreamBuildUrls = \[:\]/) assert.match(pipeline, /downstreamBuildUrls\[service\] = downstreamBuild\.absoluteUrl/) assert.match(pipeline, /downstreamBuildNumbers\[service\] = downstreamBuild\.number/) - assert.match(pipeline, /unstable \{[\s\S]*?'\u26a0\ufe0f \u751f\u4ea7\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/) - assert.match(pipeline, /\u7ed3\u679c\uff1a\u6210\u529f \$\{successCount\} \/ \u5931\u8d25 \$\{failedServices\.size\(\)\}/) - assert.match(pipeline, /\u5931\u8d25\u670d\u52a1\uff1a\$\{failedLinks/) - assert.match(pipeline, /failure \{[\s\S]*?'\u274c \u751f\u4ea7\u53d1\u5e03\u5931\u8d25'/) - assert.match(pipeline, /aborted \{[\s\S]*?'\u23f9 \u751f\u4ea7\u90e8\u7f72\u5df2\u53d6\u6d88'/) - assert.match(pipeline, /> \u8017\u65f6\uff1a\$\{duration\}/) - assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/) - assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/) + assert.match(pipeline, /downstreamResults\.findAll \{ service, result -> result != 'SUCCESS' \}/) + assert.match(pipeline, /if \(failed\) \{[\s\S]*?unstable\(/) + assert.doesNotMatch(pipeline + servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/) }) diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index cb77b3b15..f4f82baae 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -69,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false }) { +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -82,13 +82,32 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting await mkdir(mockBin) await writeFile(dockerPath, mockDocker, 'utf8') await chmod(dockerPath, 0o755) - await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876','MQ_TOPIC_PREFIX=test-','AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8') + if (remote) { + await writeFile(join(mockBin, 'ssh'), `#!/usr/bin/env sh +set -eu +for argument in "$@"; do command="$argument"; done +printf '%s\\n' "$command" >> "$MOCK_SSH_LOG" +exec sh -c "$command" +`) + await writeFile(join(mockBin, 'scp'), `#!/usr/bin/env sh +set -eu +while [ "$#" -gt 2 ]; do shift; done +cp "$1" "\${2#*:}" +`) + await chmod(join(mockBin, 'ssh'), 0o755) + await chmod(join(mockBin, 'scp'), 0o755) + await writeFile(join(temporaryDirectory, 'key'), 'fixture') + await writeFile(join(temporaryDirectory, 'known-hosts'), 'fixture') + } + + await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876',`MQ_TOPIC_PREFIX=${topicPrefix}`,'AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8') await writeFile(logFile, '', 'utf8') await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8') await writeFile(healthStateFile, 'healthy\n', 'utf8') await writeFile(runtimeStateFile, 'running\n', 'utf8') const result = spawnSync(shell, [ + '-c', 'PATH="$MOCK_BIN:$PATH"; export PATH; exec sh "$@"', 'fixture', deployScript.replaceAll('\\', '/'), environment, 'registry.local/group/firstleg', @@ -101,7 +120,12 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting env: { ...process.env, PATH: `${mockBin}${delimiter}${process.env.PATH ?? ''}`, - TARGET_IS_LOCAL: 'true', + MOCK_BIN: mockBin.replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`), + TARGET_IS_LOCAL: remote ? 'false' : 'true', + TARGET_SSH_TARGET: 'fixture@fixture', + TARGET_SSH_KEY: join(temporaryDirectory, 'key').replaceAll('\\', '/'), + TARGET_SSH_KNOWN_HOSTS: join(temporaryDirectory, 'known-hosts').replaceAll('\\', '/'), + MOCK_SSH_LOG: join(temporaryDirectory, 'ssh.log').replaceAll('\\', '/'), ALLOW_SAME_CONTRACT_ROLLBACK: String(allowRollback), REGISTRY_USER: 'ci-user', REGISTRY_PASSWORD: 'ci-password', @@ -120,8 +144,9 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting const dockerLog = await readFile(logFile, 'utf8') const currentImage = (await readFile(currentImageFile, 'utf8')).trim() + const sshLog = remote ? await readFile(join(temporaryDirectory, 'ssh.log'), 'utf8').catch(() => '') : '' await rm(temporaryDirectory, { recursive: true, force: true }) - return { ...result, dockerLog, currentImage } + return { ...result, dockerLog, currentImage, sshLog } } test('successful deployment keeps the new healthy image', async (context) => { @@ -182,8 +207,8 @@ test('architecture switch failure does not roll back one old service', async () assert.doesNotMatch(result.dockerLog,/pull .*:oldgood/) assert.match(result.stderr,/Automatic rollback disabled/) }) -test('non TEST environments and retired services fail before Docker', async () => { - for(const options of [{environment:'production'},{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){ +test('unsupported environments and retired services fail before Docker', async () => { + for(const options of [{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){ const result=await runDeployment({imageTag:'newgood',...options}) assert.equal(result.status,2) assert.equal(result.dockerLog,'') @@ -200,3 +225,36 @@ test('SQL initializer rejects absent authorization before any Docker call',()=>{ assert.equal(result.status,2) assert.match(result.stderr,/Explicit TEST SQL authorization required/) }) + + +test('gray deploy preserves health checks and uses an isolated Compose project', async () => { + const result = await runDeployment({ imageTag: 'graygood', environment: 'production', allowRollback: false }) + assert.equal(result.status, 0, result.stderr) + assert.match(result.dockerLog, /--project-name firstleg-production/) + assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/) +}) + +test('gray deployment rejects TEST message scope before any Docker operation', async () => { + const result = await runDeployment({ imageTag: 'graygood', environment: 'production', topicPrefix: 'test-' }) + assert.equal(result.status, 2) + assert.equal(result.dockerLog, '') + assert.match(result.stderr, /MQ_TOPIC_PREFIX must be production-/) +}) + + +test('SSH gray delivery executes the same health gate and cleans its unique temporary bundle', async () => { + const result = await runDeployment({ imageTag: 'remotegray', environment: 'production', allowRollback: false, remote: true }) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:remotegray') + assert.match(result.dockerLog, /--project-name firstleg-production/) + assert.match(result.sshLog, /rm -rf -- '\/tmp\/firstleg-production-release\.[A-Za-z0-9]+'/) + assert.doesNotMatch(result.sshLog, /ci-password/) +}) + +test('SSH gray health failure remains failed and still removes the uploaded credential bundle', async () => { + const result = await runDeployment({ imageTag: 'remotebad', failingTag: 'remotebad', environment: 'production', allowRollback: false, remote: true }) + assert.equal(result.status, 1, result.stderr) + assert.match(result.stderr, /Automatic rollback disabled/) + assert.match(result.sshLog, /rm -rf -- '\/tmp\/firstleg-production-release\.[A-Za-z0-9]+'/) + assert.doesNotMatch(result.sshLog, /ci-password/) +}) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index e3a9c9c24..e4290d567 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,8 +8,8 @@ test('TEST has one complete Compose, namespace, session and no retired deploymen const compose=read('deploy/docker-compose.yml') for(const service of ['gateway','auth','iam','firstleg','finance','order','warehouse','file','print','integration-service','lastleg-service','warehouse-overseas-service','audit-service']) assert.match(compose,new RegExp('^ '+service+':','m')) assert.doesNotMatch(compose,/event-bridge|gateway-overseas|OVERSEAS_REDIS|OVERSEAS_NACOS|OVERSEAS_JWT|JWT_SECRET|test-cn|test-overseas/) - assert.match(compose,/SPRING_PROFILES_ACTIVE: test/) - assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual TEST namespace ID required\}/) + assert.match(compose,/SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\}/) + assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual environment namespace ID required\}/) assert.match(compose,/REDIS_DATABASE: \$\{REDIS_DATABASE:-0\}/) assert.match(compose,/SPRING_DATA_REDIS_USERNAME: \$\{REDIS_USERNAME:-default\}/) assert.match(compose,/JWT_JWKS_URI: http:\/\/auth:9100\/api\/auth\/\.well-known\/jwks.json/) @@ -37,10 +37,10 @@ test('every retained application imports TEST configuration with explicit namesp assert.match(common,/session-validation-uri: \$\{AUTH_SESSION_VALIDATION_URL:http:\/\/auth:9100\/internal\/auth\/accounts\/sessions\/validate\}/) assert.match(read('deploy/scripts/deploy-built-service.sh'),/Missing HEALTHCHECK; deployment failed/) }) -test('Jenkins TEST publication and Nacos import have no dual-region or production execution',()=>{ +test('Jenkins retains the unified topology across TEST and gray; Nacos import stays explicit',()=>{ for(const p of ['Jenkinsfile','Jenkinsfile.service']){ const s=read(p) - assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'production'|return 'development'|firstleg-production-env|java-dev/) + assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'development'|java-dev/) assert.doesNotMatch(s,/maven.test.skip/) } assert.match(read('Jenkinsfile.service'),/node\('java-test'\)/) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index a23e788fb..c37dcd5b7 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -28,7 +28,8 @@ test('test compilation is preserved even when execution is explicitly skipped', test('coordinator and service jobs enforce their own deployment environments', () => { assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) - assert.doesNotMatch(coordinatorPipeline,/return 'production'|return 'development'/) + assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/) + assert.doesNotMatch(coordinatorPipeline,/return 'development'/) assert.match(pipeline,/startsWith\('baiyue-firstleg-'\)/) assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-dev-'\)/) assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-prod-'\)/) @@ -63,7 +64,7 @@ test('AUTO maps shared modules to their real consumers instead of rebuilding eve }) test('TEST builds respect explicit test execution controls', () => { - assert.doesNotMatch(coordinatorPipeline,/backend-java-prod|return 'production'/) + assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/) assert.match(coordinatorPipeline,/!params.SKIP_TESTS/) assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) }) @@ -74,7 +75,7 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.match(pipeline,/lock\(resource: "firstleg-test-\$\{env.RESOLVED_SERVICE\}-deploy"/) assert.match(pipeline,/'TARGET_IS_LOCAL=true'/) assert.match(directDeployScript,/up -d --no-deps --pull never/) - assert.doesNotMatch(directDeployScript,/--remove-orphans|run_ssh|docker save/) + assert.doesNotMatch(directDeployScript,/--remove-orphans|docker save/) }) test('downstream skips tests only by explicit flag and keeps test compilation',()=>{ @@ -137,7 +138,7 @@ test('TEST deployments use explicit local env and Compose files', () => { assert.match(directDeployScript,/TEST deploy must run on the target host/) assert.match(directDeployScript,/--env-file "\$ENV_FILE"/) assert.match(directDeployScript,/-f "\$COMPOSE_FILE"/) - assert.doesNotMatch(directDeployScript,/scp|run_ssh/) + assert.match(directDeployScript,/if \[ "\$DEPLOY_ENV" = "test" \] && \[ "\$\{TARGET_IS_LOCAL:-false\}" != "true" \]/) }) test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => { @@ -178,3 +179,14 @@ test('retired bridge jobs cannot be dispatched', () => { assert.doesNotMatch(pipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) assert.doesNotMatch(coordinatorPipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) }) + + +test('gray publication reuses verified SSH credentials and the unified health executor', () => { + for (const credential of ['firstleg-production-env', 'gitea-production-pull-token', 'firstleg-production-ssh', 'firstleg-production-host', 'firstleg-production-known-hosts']) assert.ok(pipeline.includes(credential)) + assert.match(pipeline, /git merge-base --is-ancestor "\$RESOLVED_SOURCE_COMMIT" origin\/main/) + assert.match(directDeployScript, /StrictHostKeyChecking=yes/) + assert.match(directDeployScript, /trap cleanup_remote EXIT HUP INT TERM/) + assert.match(serviceCompose, /name: firstleg-\$\{DEPLOY_ENV:-test\}-network/) + assert.match(serviceCompose, /SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\},cn/) + assert.doesNotMatch(directDeployScript, /deploy-blue-green|worker|StrictHostKeyChecking=no/) +}) -- 2.54.0